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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. 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"]