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,386 @@
|
|
|
1
|
+
"""Semantic snapshot generation adapted from bridgic-browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from browser_cli.refs.models import RefData, SemanticSnapshot, SnapshotMetadata
|
|
13
|
+
from browser_cli.refs.resolver import SemanticRefResolver
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class SnapshotOptions:
|
|
18
|
+
interactive: bool = False
|
|
19
|
+
full_page: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SemanticSnapshotGenerator:
|
|
23
|
+
CONTENT_ROLES = {
|
|
24
|
+
"article",
|
|
25
|
+
"cell",
|
|
26
|
+
"columnheader",
|
|
27
|
+
"heading",
|
|
28
|
+
"main",
|
|
29
|
+
"navigation",
|
|
30
|
+
"region",
|
|
31
|
+
"rowheader",
|
|
32
|
+
}
|
|
33
|
+
ALWAYS_REF_ROLES = {
|
|
34
|
+
"cell",
|
|
35
|
+
"columnheader",
|
|
36
|
+
"gridcell",
|
|
37
|
+
"listitem",
|
|
38
|
+
"option",
|
|
39
|
+
"row",
|
|
40
|
+
"rowheader",
|
|
41
|
+
}
|
|
42
|
+
LANDMARK_ROLES = {
|
|
43
|
+
"banner",
|
|
44
|
+
"contentinfo",
|
|
45
|
+
"complementary",
|
|
46
|
+
"form",
|
|
47
|
+
"main",
|
|
48
|
+
"navigation",
|
|
49
|
+
"region",
|
|
50
|
+
"search",
|
|
51
|
+
}
|
|
52
|
+
SEMANTIC_ROLES = {
|
|
53
|
+
"alert",
|
|
54
|
+
"blockquote",
|
|
55
|
+
"caption",
|
|
56
|
+
"code",
|
|
57
|
+
"definition",
|
|
58
|
+
"document",
|
|
59
|
+
"emphasis",
|
|
60
|
+
"feed",
|
|
61
|
+
"figure",
|
|
62
|
+
"img",
|
|
63
|
+
"insertion",
|
|
64
|
+
"log",
|
|
65
|
+
"marquee",
|
|
66
|
+
"math",
|
|
67
|
+
"meter",
|
|
68
|
+
"note",
|
|
69
|
+
"paragraph",
|
|
70
|
+
"status",
|
|
71
|
+
"strong",
|
|
72
|
+
"subscript",
|
|
73
|
+
"superscript",
|
|
74
|
+
"term",
|
|
75
|
+
"text",
|
|
76
|
+
"time",
|
|
77
|
+
"timer",
|
|
78
|
+
"tooltip",
|
|
79
|
+
}
|
|
80
|
+
STRUCTURAL_ROLES = {"directory", "list", "row", "rowgroup", "table", "toolbar"}
|
|
81
|
+
STRUCTURAL_NOISE_ROLES = SemanticRefResolver.STRUCTURAL_NOISE_ROLES
|
|
82
|
+
TEXT_LEAF_ROLES = SemanticRefResolver.TEXT_LEAF_ROLES
|
|
83
|
+
INTERACTIVE_ROLES = SemanticRefResolver.INTERACTIVE_ROLES
|
|
84
|
+
_REF_NAMESPACE = "browser-cli-semantic-v1"
|
|
85
|
+
_YAML_QUOTE_PATTERN = re.compile(r"^(\s*-\s*)'(.+)'(:{0,1})\s*$")
|
|
86
|
+
_LINE_PATTERN = re.compile(
|
|
87
|
+
r"^(\s*-\s*)"
|
|
88
|
+
r"([A-Za-z0-9_/-]+)"
|
|
89
|
+
r'(?:\s+"((?:[^"\\]|\\.)*)")?'
|
|
90
|
+
r"(.*)$"
|
|
91
|
+
)
|
|
92
|
+
_REF_CLEAN_PATTERN = re.compile(r"\s*\[ref=[a-zA-Z0-9]+\]")
|
|
93
|
+
_REF_EXTRACT_PATTERN = re.compile(r"\[ref=([a-zA-Z0-9]+)\]")
|
|
94
|
+
|
|
95
|
+
async def page_snapshot_for_ai(self, page: Any) -> str:
|
|
96
|
+
page_impl = page._impl_obj
|
|
97
|
+
channel = page_impl._channel
|
|
98
|
+
result = await channel.send_return_as_dict(
|
|
99
|
+
"snapshotForAI",
|
|
100
|
+
page_impl._timeout_settings.timeout,
|
|
101
|
+
{"track": None, "timeout": 30000},
|
|
102
|
+
is_internal=True,
|
|
103
|
+
)
|
|
104
|
+
return str(result.get("full") or "")
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def _strip_yaml_quotes(cls, line: str) -> str:
|
|
108
|
+
match = cls._YAML_QUOTE_PATTERN.match(line)
|
|
109
|
+
if not match:
|
|
110
|
+
return line
|
|
111
|
+
prefix, content, colon = match.groups()
|
|
112
|
+
content = content.replace("''", "'")
|
|
113
|
+
return f"{prefix}{content}{colon}"
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def _normalize_raw_snapshot(cls, raw: str) -> str:
|
|
117
|
+
return "\n".join(cls._strip_yaml_quotes(line) for line in raw.splitlines())
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _indent_level(line: str) -> int:
|
|
121
|
+
return len(line) - len(line.lstrip(" "))
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def _build_selector(
|
|
125
|
+
cls, role: str, name: str | None = None, text_content: str | None = None
|
|
126
|
+
) -> str:
|
|
127
|
+
if name:
|
|
128
|
+
escaped_name = name.replace('"', '\\"')
|
|
129
|
+
if role in cls.TEXT_LEAF_ROLES:
|
|
130
|
+
return f'get_by_text("{escaped_name}", exact=True)'
|
|
131
|
+
return f'get_by_role("{role}", name="{escaped_name}", exact=True)'
|
|
132
|
+
if text_content:
|
|
133
|
+
escaped_text = text_content.replace('"', '\\"')
|
|
134
|
+
return f'get_by_text("{escaped_text}", exact=True)'
|
|
135
|
+
return f'get_by_role("{role}")'
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _compute_stable_ref(
|
|
139
|
+
cls,
|
|
140
|
+
role: str,
|
|
141
|
+
name: str | None,
|
|
142
|
+
frame_path: tuple[int, ...],
|
|
143
|
+
nth: int,
|
|
144
|
+
) -> str:
|
|
145
|
+
frame_str = ",".join(str(item) for item in frame_path)
|
|
146
|
+
raw = f"{cls._REF_NAMESPACE}\x1f{role}\x1f{name or ''}\x1f{frame_str}\x1f{nth}"
|
|
147
|
+
return hashlib.sha256(raw.encode("utf-8")).digest()[:4].hex()
|
|
148
|
+
|
|
149
|
+
def _should_keep(
|
|
150
|
+
self, role_lower: str, name: str | None, suffix: str, *, interactive_only: bool
|
|
151
|
+
) -> tuple[bool, bool]:
|
|
152
|
+
is_interactive = role_lower in self.INTERACTIVE_ROLES
|
|
153
|
+
has_cursor_pointer = "[cursor=pointer]" in suffix
|
|
154
|
+
has_aria_state = any(
|
|
155
|
+
token in suffix for token in ("[pressed", "[expanded", "[checked", "[selected")
|
|
156
|
+
)
|
|
157
|
+
is_disabled = "[disabled]" in suffix
|
|
158
|
+
|
|
159
|
+
should_keep = False
|
|
160
|
+
should_have_ref = False
|
|
161
|
+
if interactive_only:
|
|
162
|
+
if is_interactive or has_cursor_pointer or has_aria_state or is_disabled:
|
|
163
|
+
should_keep = True
|
|
164
|
+
should_have_ref = True
|
|
165
|
+
return should_keep, should_have_ref
|
|
166
|
+
|
|
167
|
+
if is_interactive or has_cursor_pointer or has_aria_state or is_disabled:
|
|
168
|
+
return True, True
|
|
169
|
+
if role_lower in self.ALWAYS_REF_ROLES:
|
|
170
|
+
return True, True
|
|
171
|
+
if (
|
|
172
|
+
role_lower
|
|
173
|
+
in self.CONTENT_ROLES
|
|
174
|
+
| self.LANDMARK_ROLES
|
|
175
|
+
| self.SEMANTIC_ROLES
|
|
176
|
+
| self.STRUCTURAL_ROLES
|
|
177
|
+
):
|
|
178
|
+
return True, bool(name)
|
|
179
|
+
if role_lower in self.STRUCTURAL_NOISE_ROLES:
|
|
180
|
+
return bool(name), bool(name)
|
|
181
|
+
return True, bool(name)
|
|
182
|
+
|
|
183
|
+
async def get_snapshot(
|
|
184
|
+
self,
|
|
185
|
+
page: Any,
|
|
186
|
+
*,
|
|
187
|
+
page_id: str,
|
|
188
|
+
interactive: bool = False,
|
|
189
|
+
full_page: bool = True,
|
|
190
|
+
) -> SemanticSnapshot:
|
|
191
|
+
raw_snapshot = await self.page_snapshot_for_ai(page)
|
|
192
|
+
return self.snapshot_from_raw_text(
|
|
193
|
+
raw_snapshot,
|
|
194
|
+
page_id=page_id,
|
|
195
|
+
captured_url=str(page.url),
|
|
196
|
+
interactive=interactive,
|
|
197
|
+
full_page=full_page,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def snapshot_from_raw_text(
|
|
201
|
+
self,
|
|
202
|
+
raw_snapshot: str,
|
|
203
|
+
*,
|
|
204
|
+
page_id: str,
|
|
205
|
+
captured_url: str,
|
|
206
|
+
interactive: bool = False,
|
|
207
|
+
full_page: bool = True,
|
|
208
|
+
captured_at: float | None = None,
|
|
209
|
+
) -> SemanticSnapshot:
|
|
210
|
+
if not raw_snapshot:
|
|
211
|
+
snapshot_time = time.time() if captured_at is None else captured_at
|
|
212
|
+
metadata = SnapshotMetadata(
|
|
213
|
+
snapshot_id=f"snap_{uuid.uuid4().hex[:12]}",
|
|
214
|
+
page_id=page_id,
|
|
215
|
+
captured_url=captured_url,
|
|
216
|
+
captured_at=snapshot_time,
|
|
217
|
+
interactive=interactive,
|
|
218
|
+
full_page=full_page,
|
|
219
|
+
)
|
|
220
|
+
return SemanticSnapshot(tree="(empty)", refs={}, metadata=metadata)
|
|
221
|
+
|
|
222
|
+
normalized = self._normalize_raw_snapshot(raw_snapshot)
|
|
223
|
+
snapshot_id = f"snap_{uuid.uuid4().hex[:12]}"
|
|
224
|
+
snapshot_time = time.time() if captured_at is None else captured_at
|
|
225
|
+
metadata = SnapshotMetadata(
|
|
226
|
+
snapshot_id=snapshot_id,
|
|
227
|
+
page_id=page_id,
|
|
228
|
+
captured_url=captured_url,
|
|
229
|
+
captured_at=snapshot_time,
|
|
230
|
+
interactive=interactive,
|
|
231
|
+
full_page=full_page,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
refs: dict[str, RefData] = {}
|
|
235
|
+
result: list[str] = []
|
|
236
|
+
depth_stack: list[tuple[int, bool, int, str | None]] = []
|
|
237
|
+
iframe_stack: list[tuple[int, tuple[int, ...]]] = []
|
|
238
|
+
iframe_local_counters: dict[tuple[int, ...], int] = {}
|
|
239
|
+
occurrence_counts: dict[tuple[str, str | None, tuple[int, ...]], int] = {}
|
|
240
|
+
|
|
241
|
+
def effective_depth(original_depth: int) -> int:
|
|
242
|
+
depth = 0
|
|
243
|
+
for stack_depth, kept, out_depth, _ in depth_stack:
|
|
244
|
+
if stack_depth < original_depth and kept:
|
|
245
|
+
depth = out_depth + 1
|
|
246
|
+
return depth
|
|
247
|
+
|
|
248
|
+
def nearest_parent_ref(original_depth: int) -> str | None:
|
|
249
|
+
for stack_depth, kept, _, ref in reversed(depth_stack):
|
|
250
|
+
if stack_depth < original_depth and kept and ref is not None:
|
|
251
|
+
return ref
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
for line in normalized.splitlines():
|
|
255
|
+
if not line.strip():
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
original_depth = self._indent_level(line)
|
|
259
|
+
while depth_stack and depth_stack[-1][0] >= original_depth:
|
|
260
|
+
depth_stack.pop()
|
|
261
|
+
while iframe_stack and iframe_stack[-1][0] >= original_depth:
|
|
262
|
+
iframe_stack.pop()
|
|
263
|
+
|
|
264
|
+
match = self._LINE_PATTERN.match(line)
|
|
265
|
+
if not match:
|
|
266
|
+
stripped = line.lstrip()
|
|
267
|
+
if interactive:
|
|
268
|
+
if stripped.startswith("- /") and any(kept for _, kept, _, _ in depth_stack):
|
|
269
|
+
result.append(f"{' ' * effective_depth(original_depth)}- {stripped[2:]}")
|
|
270
|
+
continue
|
|
271
|
+
if any(kept for _, kept, _, _ in depth_stack) or not depth_stack:
|
|
272
|
+
content = stripped[2:] if stripped.startswith("- ") else stripped
|
|
273
|
+
result.append(f"{' ' * effective_depth(original_depth)}- {content}")
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
_, role, name, suffix = match.groups()
|
|
277
|
+
role_lower = role.lower()
|
|
278
|
+
if not name and suffix and ":" in suffix:
|
|
279
|
+
inline_label_match = re.search(r':\s*(?:"((?:[^"\\]|\\.)*)"|([^\n]+))\s*$', suffix)
|
|
280
|
+
if inline_label_match:
|
|
281
|
+
name = (
|
|
282
|
+
inline_label_match.group(1) or inline_label_match.group(2) or ""
|
|
283
|
+
).strip() or None
|
|
284
|
+
|
|
285
|
+
if role.startswith("/"):
|
|
286
|
+
if any(kept for _, kept, _, _ in depth_stack) or not depth_stack:
|
|
287
|
+
result.append(f"{' ' * effective_depth(original_depth)}- {role}{suffix}")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
keep, have_ref = self._should_keep(
|
|
291
|
+
role_lower, name, suffix, interactive_only=interactive
|
|
292
|
+
)
|
|
293
|
+
current_out_depth = effective_depth(original_depth)
|
|
294
|
+
current_ref: str | None = None
|
|
295
|
+
|
|
296
|
+
if not keep:
|
|
297
|
+
depth_stack.append((original_depth, False, current_out_depth, None))
|
|
298
|
+
if role_lower == "iframe":
|
|
299
|
+
parent_path = iframe_stack[-1][1] if iframe_stack else ()
|
|
300
|
+
local_index = iframe_local_counters.get(parent_path, 0)
|
|
301
|
+
iframe_local_counters[parent_path] = local_index + 1
|
|
302
|
+
iframe_stack.append((original_depth, (*parent_path, local_index)))
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
clean_suffix = self._REF_CLEAN_PATTERN.sub("", suffix).strip()
|
|
306
|
+
pw_ref_match = self._REF_EXTRACT_PATTERN.search(suffix)
|
|
307
|
+
playwright_ref = pw_ref_match.group(1) if pw_ref_match else None
|
|
308
|
+
|
|
309
|
+
enhanced = f"- {role}" if interactive else f"{' ' * current_out_depth}- {role}"
|
|
310
|
+
if name:
|
|
311
|
+
enhanced += f' "{name}"'
|
|
312
|
+
|
|
313
|
+
if have_ref:
|
|
314
|
+
frame_path = iframe_stack[-1][1] if iframe_stack else ()
|
|
315
|
+
occurrence_key = (role_lower, name, frame_path)
|
|
316
|
+
nth = occurrence_counts.get(occurrence_key, 0)
|
|
317
|
+
occurrence_counts[occurrence_key] = nth + 1
|
|
318
|
+
ref = self._compute_stable_ref(role_lower, name, frame_path, nth)
|
|
319
|
+
current_ref = ref
|
|
320
|
+
text_content = None
|
|
321
|
+
if clean_suffix and ":" in clean_suffix:
|
|
322
|
+
text_match = re.search(r':\s*"?([^"]+)"?\s*$', clean_suffix)
|
|
323
|
+
if text_match:
|
|
324
|
+
candidate = text_match.group(1).strip()
|
|
325
|
+
if candidate and candidate != name:
|
|
326
|
+
text_content = candidate
|
|
327
|
+
ref_data = RefData(
|
|
328
|
+
ref=ref,
|
|
329
|
+
role=role_lower,
|
|
330
|
+
name=name,
|
|
331
|
+
nth=nth,
|
|
332
|
+
text_content=text_content,
|
|
333
|
+
tag=None,
|
|
334
|
+
interactive=role_lower in self.INTERACTIVE_ROLES,
|
|
335
|
+
parent_ref=nearest_parent_ref(original_depth),
|
|
336
|
+
frame_path=frame_path,
|
|
337
|
+
playwright_ref=playwright_ref,
|
|
338
|
+
selector_recipe=self._build_selector(role_lower, name, text_content),
|
|
339
|
+
snapshot_id=snapshot_id,
|
|
340
|
+
page_id=page_id,
|
|
341
|
+
captured_url=captured_url,
|
|
342
|
+
captured_at=snapshot_time,
|
|
343
|
+
)
|
|
344
|
+
refs[ref] = ref_data
|
|
345
|
+
enhanced += f" [ref={ref}]"
|
|
346
|
+
if nth > 0 and name:
|
|
347
|
+
enhanced += f" [nth={nth}]"
|
|
348
|
+
|
|
349
|
+
depth_stack.append((original_depth, keep, current_out_depth, current_ref))
|
|
350
|
+
if role_lower == "iframe":
|
|
351
|
+
parent_path = iframe_stack[-1][1] if iframe_stack else ()
|
|
352
|
+
local_index = iframe_local_counters.get(parent_path, 0)
|
|
353
|
+
iframe_local_counters[parent_path] = local_index + 1
|
|
354
|
+
iframe_stack.append((original_depth, (*parent_path, local_index)))
|
|
355
|
+
|
|
356
|
+
if clean_suffix:
|
|
357
|
+
if name and ":" in clean_suffix:
|
|
358
|
+
colon_match = re.search(r":\s*(.+?)\s*$", clean_suffix)
|
|
359
|
+
if colon_match:
|
|
360
|
+
raw_text = colon_match.group(1).strip()
|
|
361
|
+
if raw_text.startswith('"') and raw_text.endswith('"'):
|
|
362
|
+
raw_text = raw_text[1:-1]
|
|
363
|
+
if raw_text == name:
|
|
364
|
+
clean_suffix = clean_suffix[: colon_match.start()].rstrip()
|
|
365
|
+
if clean_suffix == ":":
|
|
366
|
+
enhanced += ":"
|
|
367
|
+
elif clean_suffix.startswith(":"):
|
|
368
|
+
enhanced += clean_suffix
|
|
369
|
+
elif clean_suffix.endswith(":"):
|
|
370
|
+
enhanced += f" {clean_suffix[:-1]}:"
|
|
371
|
+
else:
|
|
372
|
+
enhanced += f" {clean_suffix}"
|
|
373
|
+
|
|
374
|
+
result.append(enhanced)
|
|
375
|
+
|
|
376
|
+
dedup_counts: dict[tuple[str, str | None, tuple[int, ...]], int] = {}
|
|
377
|
+
for item in refs.values():
|
|
378
|
+
dedup_counts[(item.role, item.name, item.frame_path)] = (
|
|
379
|
+
dedup_counts.get((item.role, item.name, item.frame_path), 0) + 1
|
|
380
|
+
)
|
|
381
|
+
for ref, data in list(refs.items()):
|
|
382
|
+
if dedup_counts[(data.role, data.name, data.frame_path)] == 1:
|
|
383
|
+
refs[ref] = replace(data, nth=None)
|
|
384
|
+
|
|
385
|
+
tree = "\n".join(result) if result else "(empty)"
|
|
386
|
+
return SemanticSnapshot(tree=tree, refs=refs, metadata=metadata)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Semantic ref dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True, frozen=True)
|
|
11
|
+
class SnapshotMetadata:
|
|
12
|
+
snapshot_id: str
|
|
13
|
+
page_id: str
|
|
14
|
+
captured_url: str
|
|
15
|
+
captured_at: float
|
|
16
|
+
interactive: bool
|
|
17
|
+
full_page: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, frozen=True)
|
|
21
|
+
class RefData:
|
|
22
|
+
ref: str
|
|
23
|
+
role: str
|
|
24
|
+
name: str | None = None
|
|
25
|
+
nth: int | None = None
|
|
26
|
+
text_content: str | None = None
|
|
27
|
+
tag: str | None = None
|
|
28
|
+
interactive: bool = False
|
|
29
|
+
parent_ref: str | None = None
|
|
30
|
+
frame_path: tuple[int, ...] = ()
|
|
31
|
+
playwright_ref: str | None = None
|
|
32
|
+
selector_recipe: str | None = None
|
|
33
|
+
snapshot_id: str = ""
|
|
34
|
+
page_id: str = ""
|
|
35
|
+
captured_url: str = ""
|
|
36
|
+
captured_at: float = 0.0
|
|
37
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def to_summary(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"ref": self.ref,
|
|
42
|
+
"role": self.role,
|
|
43
|
+
"name": self.name,
|
|
44
|
+
"nth": self.nth,
|
|
45
|
+
"text_content": self.text_content,
|
|
46
|
+
"tag": self.tag,
|
|
47
|
+
"interactive": self.interactive,
|
|
48
|
+
"parent_ref": self.parent_ref,
|
|
49
|
+
"frame_path": list(self.frame_path),
|
|
50
|
+
"playwright_ref": self.playwright_ref,
|
|
51
|
+
"selector_recipe": self.selector_recipe,
|
|
52
|
+
"snapshot_id": self.snapshot_id,
|
|
53
|
+
"page_id": self.page_id,
|
|
54
|
+
"captured_url": self.captured_url,
|
|
55
|
+
"captured_at": self.captured_at,
|
|
56
|
+
**self.extra,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True, frozen=True)
|
|
61
|
+
class SemanticSnapshot:
|
|
62
|
+
tree: str
|
|
63
|
+
refs: dict[str, RefData]
|
|
64
|
+
metadata: SnapshotMetadata
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True, frozen=True)
|
|
68
|
+
class SnapshotInput:
|
|
69
|
+
raw_snapshot: str
|
|
70
|
+
captured_url: str
|
|
71
|
+
captured_at: float = field(default_factory=time.time)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True, frozen=True)
|
|
75
|
+
class LocatorSpec:
|
|
76
|
+
ref: str
|
|
77
|
+
role: str
|
|
78
|
+
name: str | None = None
|
|
79
|
+
text_content: str | None = None
|
|
80
|
+
match_text: str | None = None
|
|
81
|
+
child_text: str | None = None
|
|
82
|
+
nth: int | None = None
|
|
83
|
+
tag: str | None = None
|
|
84
|
+
interactive: bool = False
|
|
85
|
+
frame_path: tuple[int, ...] = ()
|
|
86
|
+
selector_recipe: str | None = None
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"ref": self.ref,
|
|
91
|
+
"role": self.role,
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"text_content": self.text_content,
|
|
94
|
+
"match_text": self.match_text,
|
|
95
|
+
"child_text": self.child_text,
|
|
96
|
+
"nth": self.nth,
|
|
97
|
+
"tag": self.tag,
|
|
98
|
+
"interactive": self.interactive,
|
|
99
|
+
"frame_path": list(self.frame_path),
|
|
100
|
+
"selector_recipe": self.selector_recipe,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_dict(cls, payload: dict[str, Any]) -> LocatorSpec:
|
|
105
|
+
return cls(
|
|
106
|
+
ref=str(payload.get("ref") or "").strip(),
|
|
107
|
+
role=str(payload.get("role") or "").strip(),
|
|
108
|
+
name=(str(payload["name"]) if payload.get("name") is not None else None),
|
|
109
|
+
text_content=(
|
|
110
|
+
str(payload["text_content"]) if payload.get("text_content") is not None else None
|
|
111
|
+
),
|
|
112
|
+
match_text=(
|
|
113
|
+
str(payload["match_text"]) if payload.get("match_text") is not None else None
|
|
114
|
+
),
|
|
115
|
+
child_text=(
|
|
116
|
+
str(payload["child_text"]) if payload.get("child_text") is not None else None
|
|
117
|
+
),
|
|
118
|
+
nth=(int(payload["nth"]) if payload.get("nth") is not None else None),
|
|
119
|
+
tag=(str(payload["tag"]) if payload.get("tag") is not None else None),
|
|
120
|
+
interactive=bool(payload.get("interactive")),
|
|
121
|
+
frame_path=tuple(int(item) for item in (payload.get("frame_path") or [])),
|
|
122
|
+
selector_recipe=(
|
|
123
|
+
str(payload["selector_recipe"])
|
|
124
|
+
if payload.get("selector_recipe") is not None
|
|
125
|
+
else None
|
|
126
|
+
),
|
|
127
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tab-scoped semantic snapshot registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from browser_cli.refs.models import RefData, SemanticSnapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class PageSnapshotState:
|
|
12
|
+
snapshot_id: str
|
|
13
|
+
tree: str
|
|
14
|
+
refs: dict[str, RefData]
|
|
15
|
+
captured_url: str
|
|
16
|
+
captured_at: float
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_snapshot(cls, snapshot: SemanticSnapshot) -> PageSnapshotState:
|
|
20
|
+
return cls(
|
|
21
|
+
snapshot_id=snapshot.metadata.snapshot_id,
|
|
22
|
+
tree=snapshot.tree,
|
|
23
|
+
refs=dict(snapshot.refs),
|
|
24
|
+
captured_url=snapshot.metadata.captured_url,
|
|
25
|
+
captured_at=snapshot.metadata.captured_at,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SnapshotRegistry:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._latest_by_page: dict[str, PageSnapshotState] = {}
|
|
32
|
+
|
|
33
|
+
def store(self, snapshot: SemanticSnapshot) -> PageSnapshotState:
|
|
34
|
+
state = PageSnapshotState.from_snapshot(snapshot)
|
|
35
|
+
self._latest_by_page[snapshot.metadata.page_id] = state
|
|
36
|
+
return state
|
|
37
|
+
|
|
38
|
+
def get(self, page_id: str) -> PageSnapshotState | None:
|
|
39
|
+
return self._latest_by_page.get(page_id)
|
|
40
|
+
|
|
41
|
+
def get_ref(self, page_id: str, ref: str) -> RefData | None:
|
|
42
|
+
state = self.get(page_id)
|
|
43
|
+
if state is None:
|
|
44
|
+
return None
|
|
45
|
+
return state.refs.get(ref)
|
|
46
|
+
|
|
47
|
+
def clear_page(self, page_id: str) -> None:
|
|
48
|
+
self._latest_by_page.pop(page_id, None)
|
|
49
|
+
|
|
50
|
+
def clear(self) -> None:
|
|
51
|
+
self._latest_by_page.clear()
|