pi-lean-portal 0.1.0
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.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pi_browser_bridge — Shared Python bridge library for pi-lean-portal.
|
|
3
|
+
|
|
4
|
+
Provides the infrastructure needed to build Python browser automation
|
|
5
|
+
backends that communicate with the TypeScript ``PythonPluginAdapter``
|
|
6
|
+
via JSON-RPC 2.0 over stdin/stdout.
|
|
7
|
+
|
|
8
|
+
Key components
|
|
9
|
+
--------------
|
|
10
|
+
* :class:`BrowserBridge` — base class; subclass and override
|
|
11
|
+
``create_browser_session()`` and operation methods.
|
|
12
|
+
* :class:`PlaywrightBridge` — Playwright-specific base extracted from
|
|
13
|
+
the Chromium reference; parameterizes engine, UA, and launch args.
|
|
14
|
+
* :mod:`.transport` — JSON-RPC 2.0 transport over stdin/stdout.
|
|
15
|
+
* :mod:`.accessibility` — Playwright accessibility snapshot parser,
|
|
16
|
+
mirroring the TypeScript version.
|
|
17
|
+
* :mod:`.bot_detection` — anti-automation / bot detection signal
|
|
18
|
+
matcher, mirroring the TypeScript version.
|
|
19
|
+
|
|
20
|
+
Shipped bridges
|
|
21
|
+
---------------
|
|
22
|
+
* ``backends/chromium-py/bridge.py`` — ``ChromiumPyBridge``, a thin
|
|
23
|
+
subclass of :class:`PlaywrightBridge` driving Chromium. Ships as the
|
|
24
|
+
parity reference for Python Chromium-based stealth backends.
|
|
25
|
+
* ``backends/firefox-py/bridge.py`` — ``FirefoxPyBridge``, a thin
|
|
26
|
+
subclass driving Firefox. Ships as the parity reference for Python
|
|
27
|
+
Firefox-based stealth backends.
|
|
28
|
+
|
|
29
|
+
Quick start
|
|
30
|
+
-----------
|
|
31
|
+
::
|
|
32
|
+
|
|
33
|
+
from pi_browser_bridge import BrowserBridge
|
|
34
|
+
|
|
35
|
+
class MyBridge(BrowserBridge):
|
|
36
|
+
def create_browser_session(self, task_id, config):
|
|
37
|
+
# launch browser, return session dict
|
|
38
|
+
...
|
|
39
|
+
def do_navigate(self, task_id, url, timeout_ms):
|
|
40
|
+
# navigate, return result dict
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
MyBridge().run()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from .bridge import BrowserBridge, SessionNotFoundError, InvalidParamsError
|
|
48
|
+
from .playwright_base import PlaywrightBridge
|
|
49
|
+
from .accessibility import (
|
|
50
|
+
AriaCachedNode,
|
|
51
|
+
AriaParseResult,
|
|
52
|
+
parse_snapshot,
|
|
53
|
+
build_locator_args,
|
|
54
|
+
INTERACTIVE_ROLES,
|
|
55
|
+
INFORMATIONAL_ROLES,
|
|
56
|
+
)
|
|
57
|
+
from .bot_detection import check_bot_detection
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"BrowserBridge",
|
|
61
|
+
"PlaywrightBridge",
|
|
62
|
+
"SessionNotFoundError",
|
|
63
|
+
"InvalidParamsError",
|
|
64
|
+
"AriaCachedNode",
|
|
65
|
+
"AriaParseResult",
|
|
66
|
+
"parse_snapshot",
|
|
67
|
+
"build_locator_args",
|
|
68
|
+
"INTERACTIVE_ROLES",
|
|
69
|
+
"INFORMATIONAL_ROLES",
|
|
70
|
+
"check_bot_detection",
|
|
71
|
+
]
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Accessibility tree utilities for the Python bridge.
|
|
3
|
+
|
|
4
|
+
Parses Playwright's page.aria_snapshot() output into an
|
|
5
|
+
LLM-friendly text format with @e1, @e2 element references. Caches
|
|
6
|
+
parsed nodes so the TypeScript adapter can map interactions back via
|
|
7
|
+
get_by_role().
|
|
8
|
+
|
|
9
|
+
Mirrors the logic in core/shared/accessibility-tree.ts.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# ─── Role classification sets ─────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
INTERACTIVE_ROLES: set[str] = {
|
|
19
|
+
"button",
|
|
20
|
+
"link",
|
|
21
|
+
"textbox",
|
|
22
|
+
"searchbox",
|
|
23
|
+
"combobox",
|
|
24
|
+
"checkbox",
|
|
25
|
+
"radio",
|
|
26
|
+
"heading",
|
|
27
|
+
"listbox",
|
|
28
|
+
"option",
|
|
29
|
+
"menuitem",
|
|
30
|
+
"menuitemcheckbox",
|
|
31
|
+
"menuitemradio",
|
|
32
|
+
"tab",
|
|
33
|
+
"treeitem",
|
|
34
|
+
"switch",
|
|
35
|
+
"slider",
|
|
36
|
+
"spinbutton",
|
|
37
|
+
"progressbar",
|
|
38
|
+
"meter",
|
|
39
|
+
"scrollbar",
|
|
40
|
+
"gridcell",
|
|
41
|
+
"cell",
|
|
42
|
+
"columnheader",
|
|
43
|
+
"rowheader",
|
|
44
|
+
"tabpanel",
|
|
45
|
+
"img",
|
|
46
|
+
"figure",
|
|
47
|
+
"listitem",
|
|
48
|
+
"dialog",
|
|
49
|
+
"alertdialog",
|
|
50
|
+
"tooltip",
|
|
51
|
+
"navigation",
|
|
52
|
+
"banner",
|
|
53
|
+
"form",
|
|
54
|
+
"search",
|
|
55
|
+
"toolbar",
|
|
56
|
+
"menu",
|
|
57
|
+
"menubar",
|
|
58
|
+
"note",
|
|
59
|
+
"alert",
|
|
60
|
+
"status",
|
|
61
|
+
"list",
|
|
62
|
+
"table",
|
|
63
|
+
"grid",
|
|
64
|
+
"treegrid",
|
|
65
|
+
"article",
|
|
66
|
+
"section",
|
|
67
|
+
"blockquote",
|
|
68
|
+
"code",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
INFORMATIONAL_ROLES: set[str] = {
|
|
72
|
+
"paragraph",
|
|
73
|
+
"text",
|
|
74
|
+
"group",
|
|
75
|
+
"region",
|
|
76
|
+
"main",
|
|
77
|
+
"complementary",
|
|
78
|
+
"contentinfo",
|
|
79
|
+
"definition",
|
|
80
|
+
"term",
|
|
81
|
+
"math",
|
|
82
|
+
"marquee",
|
|
83
|
+
"timer",
|
|
84
|
+
"log",
|
|
85
|
+
"deletion",
|
|
86
|
+
"insertion",
|
|
87
|
+
"mark",
|
|
88
|
+
"suggestion",
|
|
89
|
+
"comment",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ─── Data structures ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class AriaCachedNode:
|
|
97
|
+
ref: str
|
|
98
|
+
role: str
|
|
99
|
+
name: str
|
|
100
|
+
props: list[str]
|
|
101
|
+
depth: int
|
|
102
|
+
raw: str
|
|
103
|
+
occurrence_index: int = 0
|
|
104
|
+
"""0-based position among siblings with same role+name in the snapshot."""
|
|
105
|
+
parent_ref: Optional[str] = None
|
|
106
|
+
"""Ref of the nearest interactive ancestor (for subtree queries)."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class AriaParseResult:
|
|
111
|
+
text: str
|
|
112
|
+
elements: dict[str, AriaCachedNode] = field(default_factory=dict)
|
|
113
|
+
count: int = 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ─── Role icons (same mapping as TypeScript) ───────────────—──────────
|
|
117
|
+
|
|
118
|
+
_ROLE_ICONS: dict[str, str] = {
|
|
119
|
+
"button": "\U0001f518 ", # 🔘
|
|
120
|
+
"link": "\U0001f517 ", # 🔗
|
|
121
|
+
"textbox": "\U0001f4dd ", # 📝
|
|
122
|
+
"searchbox": "\U0001f50d ", # 🔍
|
|
123
|
+
"combobox": "\U0001f4cb ", # 📋
|
|
124
|
+
"checkbox": "\u2611 ", # ☑
|
|
125
|
+
"radio": "\u25cb ", # ○
|
|
126
|
+
"heading": "\U0001f4cc ", # 📌
|
|
127
|
+
"listbox": "\U0001f4cb ", # 📋
|
|
128
|
+
"option": "\u2022 ", # •
|
|
129
|
+
"tab": "\U0001f4d1 ", # 📑
|
|
130
|
+
"switch": "\U0001f500 ", # 🔀
|
|
131
|
+
"slider": "\U0001f527 ", # 🔧
|
|
132
|
+
"spinbutton": "\U0001f522 ", # 🔢
|
|
133
|
+
"img": "\U0001f5bc ", # 🖼
|
|
134
|
+
"figure": "\U0001f5bc ", # 🖼
|
|
135
|
+
"table": "\U0001f4ca ", # 📊
|
|
136
|
+
"grid": "\U0001f4ca ", # 📊
|
|
137
|
+
"gridcell": "\u25ab ", # ▫
|
|
138
|
+
"cell": "\u25ab ", # ▫
|
|
139
|
+
"dialog": "\U0001f4ac ", # 💬
|
|
140
|
+
"alertdialog": "\u26a0 ", # ⚠
|
|
141
|
+
"navigation": "\U0001f9ed ", # 🧭
|
|
142
|
+
"banner": "\U0001f4f0 ", # 📰
|
|
143
|
+
"main": "\U0001f4c4 ", # 📄
|
|
144
|
+
"complementary": "\U0001f4ce ", # 📎
|
|
145
|
+
"contentinfo": "\u2139 ", # ℹ
|
|
146
|
+
"form": "\U0001f4dd ", # 📝
|
|
147
|
+
"search": "\U0001f50d ", # 🔍
|
|
148
|
+
"group": "\U0001f4e6 ", # 📦
|
|
149
|
+
"toolbar": "\U0001f527 ", # 🔧
|
|
150
|
+
"treeitem": "\u2022 ", # •
|
|
151
|
+
"menu": "\U0001f4cb ", # 📋
|
|
152
|
+
"menubar": "\U0001f4cb ", # 📋
|
|
153
|
+
"paragraph": "\U0001f4c3 ", # 📃
|
|
154
|
+
"article": "\U0001f4f0 ", # 📰
|
|
155
|
+
"section": "\U0001f4c4 ", # 📄
|
|
156
|
+
"list": "\U0001f4cb ", # 📋
|
|
157
|
+
"listitem": "\u2022 ", # •
|
|
158
|
+
"note": "\U0001f4dd ", # 📝
|
|
159
|
+
"alert": "\U0001f514 ", # 🔔
|
|
160
|
+
"status": "\U0001f4ca ", # 📊
|
|
161
|
+
"code": "\U0001f4bb ", # 💻
|
|
162
|
+
"blockquote": "\U0001f4ac ", # 💬
|
|
163
|
+
"columnheader": "\U0001f4ca ", # 📊
|
|
164
|
+
"comment": "\U0001f4ac ", # 💬
|
|
165
|
+
"definition": "\U0001f4d6 ", # 📖
|
|
166
|
+
"deletion": "\u274c ", # ❌
|
|
167
|
+
"insertion": "\u2795 ", # ➕
|
|
168
|
+
"log": "\U0001f4cb ", # 📋
|
|
169
|
+
"mark": "\U0001f58d \ufe0f ", # 🖍️
|
|
170
|
+
"marquee": "\U0001f4dc ", # 📜
|
|
171
|
+
"math": "\U0001f9ee ", # 🧮
|
|
172
|
+
"menuitem": "\U0001f4cb ", # 📋
|
|
173
|
+
"menuitemcheckbox": "\u2611 ", # ☑
|
|
174
|
+
"menuitemradio": "\u25cb ", # ○
|
|
175
|
+
"meter": "\U0001f4ca ", # 📊
|
|
176
|
+
"progressbar": "\u23f3 ", # ⏳
|
|
177
|
+
"region": "\U0001f4e6 ", # 📦
|
|
178
|
+
"rowheader": "\U0001f4ca ", # 📊
|
|
179
|
+
"scrollbar": "\U0001f4dc ", # 📜
|
|
180
|
+
"suggestion": "\U0001f4a1 ", # 💡
|
|
181
|
+
"tabpanel": "\U0001f4d1 ", # 📑
|
|
182
|
+
"term": "\U0001f4d6 ", # 📖
|
|
183
|
+
"text": "\U0001f4dd ", # 📝
|
|
184
|
+
"timer": "\u23f1 \ufe0f ", # ⏱️
|
|
185
|
+
"tooltip": "\U0001f4a1 ", # 💡
|
|
186
|
+
"treegrid": "\U0001f4ca ", # 📊
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _role_icon(role: str) -> str:
|
|
191
|
+
return _ROLE_ICONS.get(role, "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _truncate(s: str, max_len: int) -> str:
|
|
195
|
+
if len(s) <= max_len:
|
|
196
|
+
return s
|
|
197
|
+
return s[: max_len - 1] + "\u2026" # …
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ─── Line parser ──────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class _ParsedLine:
|
|
204
|
+
role: str
|
|
205
|
+
name: str
|
|
206
|
+
props: list[str]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _parse_line(line: str) -> Optional[_ParsedLine]:
|
|
210
|
+
"""Parse a single line from the accessibility snapshot YAML-like output.
|
|
211
|
+
|
|
212
|
+
Expected format::
|
|
213
|
+
|
|
214
|
+
- role "name" [prop1, prop2]
|
|
215
|
+
- role "name":
|
|
216
|
+
- role : text content
|
|
217
|
+
|
|
218
|
+
Returns None if the line is not a recognised element line.
|
|
219
|
+
"""
|
|
220
|
+
if not line.startswith("- "):
|
|
221
|
+
return None
|
|
222
|
+
content = line[2:].strip()
|
|
223
|
+
|
|
224
|
+
# Extract bracketed props like [level=1, checked]
|
|
225
|
+
props: list[str] = []
|
|
226
|
+
cleaned = re.sub(r"\[([^\]]+)\]", lambda m: props.append(m.group(1).strip()) or "", content).strip()
|
|
227
|
+
|
|
228
|
+
match = re.match(r"^([a-zA-Z_-]+)\s*", cleaned)
|
|
229
|
+
if not match:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
role = match.group(1).lower()
|
|
233
|
+
remainder = cleaned[match.end():].strip()
|
|
234
|
+
name = ""
|
|
235
|
+
|
|
236
|
+
# Quoted name: "name" or "name":
|
|
237
|
+
qmatch = re.match(r'^"((?:[^"\\]|\\.)*)"\s*:?\s*', remainder)
|
|
238
|
+
if qmatch:
|
|
239
|
+
name = qmatch.group(1)
|
|
240
|
+
else:
|
|
241
|
+
# Colon-text format: : text content
|
|
242
|
+
tmatch = re.match(r"^:\s*(.*)", remainder)
|
|
243
|
+
if tmatch:
|
|
244
|
+
name = tmatch.group(1).strip()[:100]
|
|
245
|
+
|
|
246
|
+
return _ParsedLine(role=role, name=name, props=props)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ─── Main parser ──────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def parse_snapshot(snap: str) -> AriaParseResult:
|
|
252
|
+
"""Parse the YAML-like output of Playwright's page.aria_snapshot().
|
|
253
|
+
|
|
254
|
+
Single-pass parser that assigns @e refs sequentially to all interactive
|
|
255
|
+
elements in DOM order. Every interactive element gets a ref —
|
|
256
|
+
no cap, no dialog prioritisation.
|
|
257
|
+
|
|
258
|
+
Full ARIA trees beyond truncation are cached to disk by the router,
|
|
259
|
+
so there is no need to budget or prioritise ref allocations.
|
|
260
|
+
|
|
261
|
+
Mirrors the TypeScript ``parseSnapshot()`` in
|
|
262
|
+
``core/shared/accessibility-tree.ts``.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
snap: The raw snapshot string.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
An AriaParseResult with formatted text, element cache, and count.
|
|
269
|
+
"""
|
|
270
|
+
lines = snap.split("\n")
|
|
271
|
+
elements: dict[str, AriaCachedNode] = {}
|
|
272
|
+
out_lines: list[str] = []
|
|
273
|
+
ref_counter = 0
|
|
274
|
+
occurrence_tracker: dict[str, int] = {}
|
|
275
|
+
# Depth-based parent stack — tracks the most recent interactive ref at each depth
|
|
276
|
+
parent_stack: list[str] = []
|
|
277
|
+
|
|
278
|
+
for raw_line in lines:
|
|
279
|
+
if not raw_line.strip():
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
depth = _count_leading_spaces(raw_line)
|
|
283
|
+
trimmed = raw_line.strip()
|
|
284
|
+
|
|
285
|
+
# Property lines (start with /) — pass through as-is
|
|
286
|
+
if trimmed.startswith("/"):
|
|
287
|
+
out_lines.append(raw_line)
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
parsed = _parse_line(trimmed)
|
|
291
|
+
if parsed is None:
|
|
292
|
+
out_lines.append(raw_line)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
role = parsed.role
|
|
296
|
+
name = parsed.name
|
|
297
|
+
props = parsed.props
|
|
298
|
+
|
|
299
|
+
# Informational roles: show in tree but no @e ref
|
|
300
|
+
if role in INFORMATIONAL_ROLES:
|
|
301
|
+
indent = " " * depth
|
|
302
|
+
icon = _role_icon(role)
|
|
303
|
+
name_part = f' "{_truncate(name, 80)}"' if name else ""
|
|
304
|
+
out_lines.append(f"{indent}{icon}{role}{name_part}")
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
# Non-interactive — skip entirely
|
|
308
|
+
if role not in INTERACTIVE_ROLES:
|
|
309
|
+
out_lines.append(raw_line)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
ref_counter += 1
|
|
313
|
+
ref = f"e{ref_counter}"
|
|
314
|
+
occ_key = f"{role}||{name}"
|
|
315
|
+
occurrence_index = occurrence_tracker.get(occ_key, 0)
|
|
316
|
+
occurrence_tracker[occ_key] = occurrence_index + 1
|
|
317
|
+
|
|
318
|
+
# Parent stack: trim entries past current depth
|
|
319
|
+
while len(parent_stack) > depth:
|
|
320
|
+
parent_stack.pop()
|
|
321
|
+
|
|
322
|
+
# Determine parentRef from the element at depth-1 (if any)
|
|
323
|
+
parent_ref: Optional[str] = None
|
|
324
|
+
if len(parent_stack) >= depth and depth > 0:
|
|
325
|
+
parent_ref = parent_stack[depth - 1]
|
|
326
|
+
|
|
327
|
+
# Push this ref onto the parent stack at its depth
|
|
328
|
+
parent_stack.append(ref)
|
|
329
|
+
|
|
330
|
+
node = AriaCachedNode(
|
|
331
|
+
ref=ref,
|
|
332
|
+
role=role,
|
|
333
|
+
name=name,
|
|
334
|
+
props=props,
|
|
335
|
+
depth=depth,
|
|
336
|
+
raw=trimmed,
|
|
337
|
+
occurrence_index=occurrence_index,
|
|
338
|
+
parent_ref=parent_ref,
|
|
339
|
+
)
|
|
340
|
+
elements[ref] = node
|
|
341
|
+
|
|
342
|
+
indent = " " * depth
|
|
343
|
+
icon = _role_icon(role)
|
|
344
|
+
ref_tag = f"@{ref}"
|
|
345
|
+
name_part = f' "{_truncate(name, 80)}"' if name else ""
|
|
346
|
+
prop_str = f" [{', '.join(props)}]" if props else ""
|
|
347
|
+
|
|
348
|
+
out_lines.append(f"{indent}{ref_tag} {icon}{role}{name_part}{prop_str}")
|
|
349
|
+
|
|
350
|
+
return AriaParseResult(
|
|
351
|
+
text="\n".join(out_lines),
|
|
352
|
+
elements=elements,
|
|
353
|
+
count=len(elements), # refs assigned, matching TS semantics
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ─── Helpers ──────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
def _count_leading_spaces(s: str) -> int:
|
|
360
|
+
match = re.match(r"^(\s*)", s)
|
|
361
|
+
return len(match.group(1)) if match else 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def build_locator_args(node: AriaCachedNode) -> tuple[str, dict]:
|
|
365
|
+
"""Build get_by_role arguments from a cached node.
|
|
366
|
+
|
|
367
|
+
Returns (role, kwargs), suitable for use as::
|
|
368
|
+
|
|
369
|
+
page.get_by_role(role, **kwargs)
|
|
370
|
+
|
|
371
|
+
The kwargs dict includes an ``occurrenceIndex`` key that callers should
|
|
372
|
+
always pass to ``.nth(n)`` on the locator to avoid strict-mode violations
|
|
373
|
+
from duplicate role+name elements."""
|
|
374
|
+
kwargs: dict = {}
|
|
375
|
+
|
|
376
|
+
if node.name:
|
|
377
|
+
kwargs["name"] = node.name
|
|
378
|
+
kwargs["exact"] = len(node.name) < 60
|
|
379
|
+
|
|
380
|
+
for prop in node.props:
|
|
381
|
+
eq_idx = prop.find("=")
|
|
382
|
+
if eq_idx > 0:
|
|
383
|
+
key = prop[:eq_idx]
|
|
384
|
+
val = prop[eq_idx + 1:]
|
|
385
|
+
if key == "level":
|
|
386
|
+
kwargs["level"] = int(val)
|
|
387
|
+
elif key == "checked":
|
|
388
|
+
kwargs["checked"] = "mixed" if val == "mixed" else True
|
|
389
|
+
elif key == "expanded":
|
|
390
|
+
kwargs["expanded"] = val == "true"
|
|
391
|
+
elif key == "pressed":
|
|
392
|
+
kwargs["pressed"] = "mixed" if val == "mixed" else True
|
|
393
|
+
elif key == "selected":
|
|
394
|
+
kwargs["selected"] = val == "true"
|
|
395
|
+
else:
|
|
396
|
+
if prop == "checked":
|
|
397
|
+
kwargs["checked"] = True
|
|
398
|
+
elif prop == "expanded":
|
|
399
|
+
kwargs["expanded"] = True
|
|
400
|
+
elif prop == "pressed":
|
|
401
|
+
kwargs["pressed"] = True
|
|
402
|
+
elif prop == "selected":
|
|
403
|
+
kwargs["selected"] = True
|
|
404
|
+
elif prop == "disabled":
|
|
405
|
+
kwargs["disabled"] = True
|
|
406
|
+
|
|
407
|
+
kwargs["occurrenceIndex"] = node.occurrence_index
|
|
408
|
+
return node.role, kwargs
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bot detection for the Python bridge.
|
|
3
|
+
|
|
4
|
+
Mirrors the logic in ``core/shared/bot-detection.ts`` ``checkPage()``.
|
|
5
|
+
|
|
6
|
+
The matching *must* run in the Python process because it needs
|
|
7
|
+
``page.title()`` and ``page.evaluate()`` for body/HTML — shipping the
|
|
8
|
+
full HTML across JSON-RPC would be prohibitively expensive. However,
|
|
9
|
+
the signal lists are pure data and live here as a single source of
|
|
10
|
+
truth for all Python-based browser backends.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
#: Block-level signals — checked against BOTH title and body text.
|
|
17
|
+
#: Mirror of TypeScript bot-detection.ts BLOCK_SIGNALS.
|
|
18
|
+
#: Only specific challenge phrases are included — generic single words
|
|
19
|
+
#: like "captcha", "cloudflare", "recaptcha" are excluded because they
|
|
20
|
+
#: cause false positives on legitimate pages mentioning them in passing.
|
|
21
|
+
_BLOCK_SIGNALS: tuple[str, ...] = (
|
|
22
|
+
"please verify you are human",
|
|
23
|
+
"attention required!",
|
|
24
|
+
"just a moment...",
|
|
25
|
+
"checking your browser",
|
|
26
|
+
"you have been blocked",
|
|
27
|
+
"sorry, you have been blocked",
|
|
28
|
+
"verify you are human",
|
|
29
|
+
"your request has been blocked",
|
|
30
|
+
"we are checking your browser",
|
|
31
|
+
"cf-challenge",
|
|
32
|
+
"_cf_chl_opt",
|
|
33
|
+
"cdn-cgi/challenge",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: Body-only string signals — high-specificity CDN patterns.
|
|
37
|
+
#: Mirror of TypeScript bot-detection.ts BODY_ONLY_SIGNALS.
|
|
38
|
+
_BODY_ONLY_SIGNALS: tuple[str, ...] = (
|
|
39
|
+
"errors.edgesuite.net",
|
|
40
|
+
"you don't have permission to access",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
#: Body-only regex patterns — checked against raw body text.
|
|
44
|
+
#: Mirror of TypeScript bot-detection.ts BODY_ONLY_PATTERNS.
|
|
45
|
+
_BODY_ONLY_PATTERNS: tuple[re.Pattern, ...] = (
|
|
46
|
+
re.compile(r"reference\s*#[a-f0-9]+(?:\.[a-f0-9]+)+", re.IGNORECASE),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
#: HTML-level CAPTCHA/widget signals (Python-only enhancement).
|
|
50
|
+
_HTML_SIGNALS: tuple[str, ...] = (
|
|
51
|
+
"recaptcha",
|
|
52
|
+
"hcaptcha",
|
|
53
|
+
"turnstile",
|
|
54
|
+
"g-recaptcha",
|
|
55
|
+
"data-sitekey",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_bot_detection(page: Any) -> bool:
|
|
60
|
+
"""Check for anti-automation / bot detection signals in the current page.
|
|
61
|
+
|
|
62
|
+
Mirrors the logic in ``bot-detection.ts`` ``checkPage()``.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
page: A Playwright sync Page object (or a mock with ``title()``
|
|
66
|
+
and ``evaluate()`` methods).
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if bot detection signals were found.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
title: str = page.title().lower()
|
|
73
|
+
except Exception:
|
|
74
|
+
title = ""
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
body_text: str = page.evaluate(
|
|
78
|
+
"() => document.body?.innerText || ''"
|
|
79
|
+
) or ""
|
|
80
|
+
body_text = body_text.lower()
|
|
81
|
+
except Exception:
|
|
82
|
+
body_text = ""
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
html: str = page.evaluate(
|
|
86
|
+
"() => document.documentElement?.innerHTML || ''"
|
|
87
|
+
) or ""
|
|
88
|
+
html = html.lower()
|
|
89
|
+
except Exception:
|
|
90
|
+
html = ""
|
|
91
|
+
|
|
92
|
+
# ── Block signals: checked against both title and body ───────
|
|
93
|
+
for signal in _BLOCK_SIGNALS:
|
|
94
|
+
if signal in title or signal in body_text:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
# ── Body-only string signals ────────────────────────────────
|
|
98
|
+
for signal in _BODY_ONLY_SIGNALS:
|
|
99
|
+
if signal in body_text:
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
# ── Body-only regex patterns ────────────────────────────────
|
|
103
|
+
for pattern in _BODY_ONLY_PATTERNS:
|
|
104
|
+
if pattern.search(body_text):
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# ── HTML-level signals (Python-only enhancement) ────────────
|
|
108
|
+
for signal in _HTML_SIGNALS:
|
|
109
|
+
if signal in html:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = ["check_bot_detection"]
|