stealth-browserctl 0.0.1__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.
- backend/__init__.py +18 -0
- backend/constants.py +20 -0
- backend/daemon_client.py +109 -0
- backend/daemon_server/README.md +143 -0
- backend/daemon_server/__init__.py +6 -0
- backend/daemon_server/browser/__init__.py +17 -0
- backend/daemon_server/browser/ariasnapshot/__init__.py +11 -0
- backend/daemon_server/browser/ariasnapshot/aria/__init__.py +22 -0
- backend/daemon_server/browser/ariasnapshot/aria/base.py +123 -0
- backend/daemon_server/browser/ariasnapshot/aria/generator_dom.py +89 -0
- backend/daemon_server/browser/ariasnapshot/aria/types.py +57 -0
- backend/daemon_server/browser/ariasnapshot/aria/types_generated.py +60 -0
- backend/daemon_server/browser/ariasnapshot/manager.py +231 -0
- backend/daemon_server/browser/browser.py +505 -0
- backend/daemon_server/browser/session_config.py +381 -0
- backend/daemon_server/commands/__init__.py +23 -0
- backend/daemon_server/commands/base_handler.py +122 -0
- backend/daemon_server/commands/core_commands.py +243 -0
- backend/daemon_server/commands/devtools_commands.py +257 -0
- backend/daemon_server/commands/dialog_commands.py +35 -0
- backend/daemon_server/commands/input_commands.py +153 -0
- backend/daemon_server/commands/interaction_commands.py +289 -0
- backend/daemon_server/commands/navigation_commands.py +144 -0
- backend/daemon_server/commands/proxy_commands.py +119 -0
- backend/daemon_server/commands/session_commands.py +72 -0
- backend/daemon_server/commands/storage_commands.py +278 -0
- backend/daemon_server/commands/web_commands.py +362 -0
- backend/daemon_server/errors.py +193 -0
- backend/daemon_server/models.py +67 -0
- backend/daemon_server/scripts/README.md +73 -0
- backend/daemon_server/scripts/__init__.py +0 -0
- backend/daemon_server/scripts/compiled/ariaSnapshot.js +1154 -0
- backend/daemon_server/scripts/package.json +16 -0
- backend/daemon_server/scripts/script_loader.py +115 -0
- backend/daemon_server/scripts/tsconfig.json +17 -0
- backend/daemon_server/scripts/typescript/ariaSnapshot.ts +1380 -0
- backend/daemon_server/server.py +184 -0
- backend/daemon_server.py +143 -0
- backend/flow/__init__.py +31 -0
- backend/flow/mitm/__init__.py +25 -0
- backend/flow/mitm/ca.crt +30 -0
- backend/flow/mitm/ca.key +52 -0
- backend/flow/mitm/config.py +320 -0
- backend/flow/mitm/cust_addons.py +418 -0
- backend/flow/mitm/custom_upstream_auth.py +57 -0
- backend/flow/mitm/inspect.py +290 -0
- backend/flow/mitm/modifier.py +348 -0
- backend/flow/mitm/request.py +414 -0
- backend/flow/mitm/server.py +478 -0
- backend/flow/mitm/tab_storage.py +781 -0
- backend/flow/mitm/utils.py +138 -0
- backend/flow/v2ray/__init__.py +26 -0
- backend/flow/v2ray/config.py +1156 -0
- backend/flow/v2ray/daemon_process.py +309 -0
- backend/flow/v2ray/manager.py +609 -0
- backend/flow/v2ray/pool.py +161 -0
- backend/flow/v2ray/process.py +327 -0
- backend/flow/v2ray/proxy_airport_cli.py +152 -0
- backend/flow/v2ray/subscribers.py +120 -0
- backend/human/__init__.py +336 -0
- backend/human/config.py +114 -0
- backend/human/keyboard.py +145 -0
- backend/human/mouse.py +172 -0
- backend/human/scroll.py +240 -0
- backend/undetected/__init__.py +1042 -0
- backend/undetected/readme.me +11 -0
- backend/undetected/stealth/__init__.py +53 -0
- backend/undetected/stealth/compiled/stealth.js +1022 -0
- backend/undetected/stealth/typescript/0_utils.ts +277 -0
- backend/undetected/stealth/typescript/chrome.app.ts +75 -0
- backend/undetected/stealth/typescript/chrome.csi.ts +32 -0
- backend/undetected/stealth/typescript/chrome.loadTimes.ts +109 -0
- backend/undetected/stealth/typescript/chrome.runtime.ts +234 -0
- backend/undetected/stealth/typescript/hairline.fix.ts +13 -0
- backend/undetected/stealth/typescript/iframe.contentWindow.ts +77 -0
- backend/undetected/stealth/typescript/media.codecs.ts +50 -0
- backend/undetected/stealth/typescript/navigator.languages.ts +7 -0
- backend/undetected/stealth/typescript/navigator.permissions.ts +23 -0
- backend/undetected/stealth/typescript/navigator.plugins.ts +216 -0
- backend/undetected/stealth/typescript/navigator.vendor.ts +7 -0
- backend/undetected/stealth/typescript/navigator.webdriver.ts +5 -0
- backend/undetected/stealth/typescript/navigator.webrtc.ts +11 -0
- backend/undetected/stealth/typescript/readme.md +3 -0
- backend/undetected/stealth/typescript/stealth.entry.ts +46 -0
- backend/undetected/stealth/typescript/tsconfig.json +17 -0
- backend/undetected/stealth/typescript/webgl.vendor.ts +24 -0
- backend/undetected/stealth/typescript/window.outerdimensions.ts +12 -0
- backend/undetected/undetected_chrome/__init__.py +60 -0
- backend/undetected/undetected_chrome/patcher.py +425 -0
- backend/undetected/undetected_firefox/__init__.py +52 -0
- backend/undetected/undetected_firefox/constants.py +49 -0
- backend/undetected/undetected_firefox/patcher.py +286 -0
- cli/__init__.py +8 -0
- frontend/__init__.py +6 -0
- frontend/commands/__init__.py +1 -0
- frontend/commands/core.py +238 -0
- frontend/commands/devtools.py +412 -0
- frontend/commands/input.py +121 -0
- frontend/commands/interaction.py +218 -0
- frontend/commands/navigation.py +128 -0
- frontend/commands/proxy.py +227 -0
- frontend/commands/session.py +119 -0
- frontend/commands/storage.py +243 -0
- frontend/commands/web.py +105 -0
- frontend/main.py +179 -0
- frontend/skill/browserctl/SKILL.md +786 -0
- frontend/skill/browserctl/references/session-management.md +200 -0
- stealth_browserctl-0.0.1.dist-info/METADATA +427 -0
- stealth_browserctl-0.0.1.dist-info/RECORD +123 -0
- stealth_browserctl-0.0.1.dist-info/WHEEL +4 -0
- stealth_browserctl-0.0.1.dist-info/entry_points.txt +3 -0
- webdriver_bidi/README.md +666 -0
- webdriver_bidi/__init__.py +597 -0
- webdriver_bidi/actions.py +1799 -0
- webdriver_bidi/driver.py +244 -0
- webdriver_bidi/errors.py +26 -0
- webdriver_bidi/local_value.py +172 -0
- webdriver_bidi/locator.py +1549 -0
- webdriver_bidi/messenger.py +194 -0
- webdriver_bidi/models.py +1119 -0
- webdriver_bidi/session.py +433 -0
- webdriver_bidi/test_bidi.ipynb +268 -0
- webdriver_bidi/wait.py +428 -0
backend/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Backend package for browserctl daemon."""
|
|
2
|
+
|
|
3
|
+
from .daemon_server.server import StealthDaemon
|
|
4
|
+
from .flow import(
|
|
5
|
+
CookieAttributes,
|
|
6
|
+
ResponseCookie,
|
|
7
|
+
RequestSummary,
|
|
8
|
+
RequestDetails,Request
|
|
9
|
+
)
|
|
10
|
+
__all__ = [
|
|
11
|
+
"StealthDaemon",
|
|
12
|
+
"CookieAttributes",
|
|
13
|
+
"ResponseCookie",
|
|
14
|
+
"RequestSummary",
|
|
15
|
+
"RequestDetails","Request"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
backend/constants.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Configuration constants for browserctl daemon."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Base directory for daemon files
|
|
6
|
+
DAEMON_DIR = Path.home() / ".browserctl"
|
|
7
|
+
|
|
8
|
+
# Base directory for v2ray bin
|
|
9
|
+
DEFAULT_V2RAY_INSTALL_PATH = DAEMON_DIR / "v2ray"
|
|
10
|
+
|
|
11
|
+
# Socket path for IPC (cross-platform)
|
|
12
|
+
# Unix: UDS (Unix Domain Socket)
|
|
13
|
+
# Windows: Named Pipe
|
|
14
|
+
DEFAULT_SOCKET_PATH = DAEMON_DIR / "daemon.sock"
|
|
15
|
+
|
|
16
|
+
# PID file for tracking daemon process
|
|
17
|
+
PID_FILE = DAEMON_DIR / "daemon.pid"
|
|
18
|
+
|
|
19
|
+
# Authentication key for IPC connection
|
|
20
|
+
AUTH_KEY = b'browserctl-daemon-v1'
|
backend/daemon_client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
module of Stealth CLI Daemon Client, it's for communicating with the daemon server using multiprocessing.connection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from multiprocessing.connection import Client
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from backend.constants import DEFAULT_SOCKET_PATH, AUTH_KEY
|
|
14
|
+
from backend.daemon_server.models import DaemonRequest, DaemonResponse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DaemonClient:
|
|
18
|
+
"""Client for communicating with the daemon server using multiprocessing.connection (cross-platform)."""
|
|
19
|
+
def __init__(self, socket_path: Optional[Path] = None):
|
|
20
|
+
self.socket_path = socket_path or DEFAULT_SOCKET_PATH
|
|
21
|
+
|
|
22
|
+
def is_daemon_running(self) -> bool:
|
|
23
|
+
"""Check if daemon is running."""
|
|
24
|
+
# On Unix, check if socket file exists
|
|
25
|
+
# On Windows, Named Pipes don't create files
|
|
26
|
+
if self.socket_path.exists():
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
# Try to connect (works on both platforms)
|
|
30
|
+
try:
|
|
31
|
+
client = Client(str(self.socket_path), authkey=AUTH_KEY)
|
|
32
|
+
client.close()
|
|
33
|
+
return True
|
|
34
|
+
except:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def send_request(
|
|
38
|
+
self,
|
|
39
|
+
command: str,
|
|
40
|
+
args: Optional[Dict[str, Any]] = None,
|
|
41
|
+
session: Optional[str] = None,
|
|
42
|
+
timeout: float = 30.0
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Send a request to the daemon."""
|
|
45
|
+
# Create structured request (defaults handled by Pydantic model)
|
|
46
|
+
request_data = {'command': command}
|
|
47
|
+
if args is not None:
|
|
48
|
+
request_data['args'] = args
|
|
49
|
+
if session is not None:
|
|
50
|
+
request_data['session'] = session
|
|
51
|
+
|
|
52
|
+
request = DaemonRequest(**request_data)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Connect to daemon (cross-platform)
|
|
56
|
+
client = Client(str(self.socket_path), authkey=AUTH_KEY)
|
|
57
|
+
|
|
58
|
+
# Send request (auto-serialization)
|
|
59
|
+
client.send(request.model_dump())
|
|
60
|
+
|
|
61
|
+
# Receive response (auto-deserialization)
|
|
62
|
+
response = client.recv()
|
|
63
|
+
|
|
64
|
+
client.close()
|
|
65
|
+
|
|
66
|
+
# Parse response
|
|
67
|
+
resp = DaemonResponse(**response)
|
|
68
|
+
|
|
69
|
+
if not resp.success:
|
|
70
|
+
raise RuntimeError(resp.error or 'Unknown error')
|
|
71
|
+
|
|
72
|
+
return resp.result
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
raise RuntimeError(f"Failed from daemon: {e}")
|
|
76
|
+
|
|
77
|
+
def start_daemon(self) -> None:
|
|
78
|
+
"""Start the daemon if not running."""
|
|
79
|
+
if self.is_daemon_running():
|
|
80
|
+
logger.debug("Daemon is already running")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Start daemon using subprocess
|
|
84
|
+
import sys
|
|
85
|
+
daemon_script = Path(__file__).parent / "daemon_server.py"
|
|
86
|
+
|
|
87
|
+
subprocess.run(
|
|
88
|
+
[sys.executable, str(daemon_script), "start"],
|
|
89
|
+
check=True
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Wait for daemon to start
|
|
93
|
+
for _ in range(20):
|
|
94
|
+
if self.is_daemon_running():
|
|
95
|
+
logger.info("Daemon started")
|
|
96
|
+
return
|
|
97
|
+
time.sleep(0.5)
|
|
98
|
+
|
|
99
|
+
raise RuntimeError("Failed to start daemon")
|
|
100
|
+
|
|
101
|
+
def stop_daemon(self) -> None:
|
|
102
|
+
"""Stop the daemon."""
|
|
103
|
+
import sys
|
|
104
|
+
daemon_script = Path(__file__).parent / "daemon_server.py"
|
|
105
|
+
|
|
106
|
+
subprocess.run(
|
|
107
|
+
[sys.executable, str(daemon_script), "stop"],
|
|
108
|
+
check=True
|
|
109
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Daemon Server Module Structure
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The daemon server has been refactored from a single 1268-line file into a well-organized module structure for better maintainability and code organization.
|
|
6
|
+
|
|
7
|
+
## Module Structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
src/backend/daemon_server/
|
|
11
|
+
├── __init__.py # Module exports
|
|
12
|
+
├── base_handler.py # Base command handler class
|
|
13
|
+
├── server.py # Main daemon server
|
|
14
|
+
├── core_commands.py # Core browser commands (open, close, goto, etc.)
|
|
15
|
+
├── interaction_commands.py # Interaction commands (click, fill, type, etc.)
|
|
16
|
+
├── navigation_commands.py # Navigation commands (tabs, back, forward, etc.)
|
|
17
|
+
├── input_commands.py # Input commands (keyboard, mouse)
|
|
18
|
+
├── dialog_commands.py # Dialog commands (accept, dismiss)
|
|
19
|
+
├── storage_commands.py # Storage commands (cookies, localStorage, sessionStorage)
|
|
20
|
+
└── session_commands.py # Session management commands
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
### Command Handler Pattern
|
|
26
|
+
|
|
27
|
+
Each command category is implemented as a separate handler class:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
class CommandHandler:
|
|
31
|
+
"""Base class for command handlers."""
|
|
32
|
+
|
|
33
|
+
def can_handle(self, command: str) -> bool:
|
|
34
|
+
"""Check if this handler can handle the command."""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def execute(self, session, command: str, args: Dict[str, Any]) -> Any:
|
|
38
|
+
"""Execute the command."""
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Handler Categories
|
|
43
|
+
|
|
44
|
+
1. **CoreCommands** - Basic browser operations
|
|
45
|
+
- `open`, `close`, `goto`, `snapshot`, `screenshot`, `pdf`, `resize`, `eval`
|
|
46
|
+
|
|
47
|
+
2. **InteractionCommands** - Element interactions
|
|
48
|
+
- `click`, `dblclick`, `type`, `fill`, `press`, `hover`, `drag`, `select`, `upload`, `check`, `uncheck`
|
|
49
|
+
|
|
50
|
+
3. **NavigationCommands** - Navigation control
|
|
51
|
+
- `go_back`, `go_forward`, `reload`, `new_tab`, `list_tab`, `close_tab`, `select_tab`
|
|
52
|
+
|
|
53
|
+
4. **InputCommands** - Keyboard and mouse input
|
|
54
|
+
- `keydown`, `keyup`, `mousemove`, `mousedown`, `mouseup`, `mousewheel`
|
|
55
|
+
|
|
56
|
+
5. **DialogCommands** - Alert/dialog handling
|
|
57
|
+
- `dialog_accept`, `dialog_dismiss`
|
|
58
|
+
|
|
59
|
+
6. **StorageCommands** - Storage management
|
|
60
|
+
- State: `state_save`, `state_load`
|
|
61
|
+
- Cookies: `cookie_list`, `cookie_get`, `cookie_set`, `cookie_delete`, `cookie_clear`
|
|
62
|
+
- LocalStorage: `localstorage_list`, `localstorage_get`, `localstorage_set`, `localstorage_delete`, `localstorage_clear`
|
|
63
|
+
- SessionStorage: `sessionstorage_list`, `sessionstorage_get`, `sessionstorage_set`, `sessionstorage_delete`, `sessionstorage_clear`
|
|
64
|
+
|
|
65
|
+
7. **SessionCommands** - Session management
|
|
66
|
+
- `list_sessions`, `close_all`, `kill_all`, `delete_data`
|
|
67
|
+
|
|
68
|
+
### Main Server
|
|
69
|
+
|
|
70
|
+
The `StealthDaemon` class in `server.py`:
|
|
71
|
+
- Manages the Unix socket server
|
|
72
|
+
- Handles client connections
|
|
73
|
+
- Routes commands to appropriate handlers
|
|
74
|
+
- Manages browser sessions via `BrowserManager`
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
class StealthDaemon:
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self.browser_manager = BrowserManager()
|
|
80
|
+
self.handlers = [
|
|
81
|
+
CoreCommands(),
|
|
82
|
+
InteractionCommands(),
|
|
83
|
+
NavigationCommands(),
|
|
84
|
+
InputCommands(),
|
|
85
|
+
DialogCommands(),
|
|
86
|
+
StorageCommands(),
|
|
87
|
+
SessionCommands(self.browser_manager),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
def _execute_command(self, session, command: str, args: Dict[str, Any]) -> Any:
|
|
91
|
+
"""Execute a command on a browser session."""
|
|
92
|
+
for handler in self.handlers:
|
|
93
|
+
if handler.can_handle(command):
|
|
94
|
+
return handler.execute(session, command, args)
|
|
95
|
+
raise ValueError(f"Unknown command: {command}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
The module is used exactly the same way as before:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from backend.daemon_server import StealthDaemon
|
|
104
|
+
|
|
105
|
+
daemon = StealthDaemon()
|
|
106
|
+
daemon.start()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Adding New Commands
|
|
110
|
+
|
|
111
|
+
To add a new command:
|
|
112
|
+
|
|
113
|
+
1. Identify the appropriate handler category
|
|
114
|
+
2. Add the command to the `COMMANDS` list
|
|
115
|
+
3. Implement the `_cmd_<command>` method
|
|
116
|
+
4. Test the command
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# In interaction_commands.py
|
|
122
|
+
|
|
123
|
+
class InteractionCommands(CommandHandler):
|
|
124
|
+
COMMANDS = [
|
|
125
|
+
'click', 'dblclick', 'type', 'fill', 'press',
|
|
126
|
+
'hover', 'drag', 'select', 'upload', 'check', 'uncheck',
|
|
127
|
+
'new_command' # Add here
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def _cmd_new_command(self, session, args: Dict[str, Any]) -> None:
|
|
131
|
+
"""New command implementation."""
|
|
132
|
+
# Implementation here
|
|
133
|
+
pass
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Future Improvements
|
|
137
|
+
|
|
138
|
+
1. **Add Type Hints**: Add comprehensive type annotations
|
|
139
|
+
2. **Add Docstrings**: Improve documentation for each method
|
|
140
|
+
3. **Add Unit Tests**: Create tests for each handler
|
|
141
|
+
4. **Add Logging**: Add more detailed logging
|
|
142
|
+
5. **Add Validation**: Add input validation for commands
|
|
143
|
+
6. **Add Error Handling**: Improve error messages and recovery
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .browser import BrowserManager,BrowserSession
|
|
2
|
+
from .ariasnapshot import AriaSnapshotManager
|
|
3
|
+
from .session_config import (
|
|
4
|
+
SessionConfig,
|
|
5
|
+
BrowserConfig,
|
|
6
|
+
TimeoutsConfig,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BrowserManager","BrowserSession",
|
|
12
|
+
"AriaSnapshotManager",
|
|
13
|
+
"SessionConfig",
|
|
14
|
+
"BrowserConfig",
|
|
15
|
+
"TimeoutsConfig",
|
|
16
|
+
|
|
17
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ARIA snapshot generation and rendering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .types import (
|
|
6
|
+
AriaBox, ScrollInfo, AriaExtraProps, AriaNode, AriaSnapshot,
|
|
7
|
+
AriaTreeOptions
|
|
8
|
+
)
|
|
9
|
+
from .base import AriaTreeGenerator, AriaTreeResult
|
|
10
|
+
from .generator_dom import DOMAriaTreeGenerator
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
# Types
|
|
14
|
+
'AriaBox', 'ScrollInfo', 'AriaExtraProps', 'AriaNode', 'AriaSnapshot',
|
|
15
|
+
'AriaTreeOptions',
|
|
16
|
+
|
|
17
|
+
# Base classes
|
|
18
|
+
'AriaTreeGenerator', 'AriaTreeResult',
|
|
19
|
+
|
|
20
|
+
# Generators
|
|
21
|
+
'DOMAriaTreeGenerator'
|
|
22
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Aria Tree Generator - Abstract interface for accessibility tree generation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Dict, Union, Optional, Any, List
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import json
|
|
9
|
+
from .types import AriaTreeOptions, AriaSnapshot, AriaNode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Helper functions for working with TypedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_node_interactable(node: AriaNode) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Check if a node is interactable.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
node: The AriaNode to check
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if the node is interactable, False otherwise
|
|
24
|
+
"""
|
|
25
|
+
# Check if disabled
|
|
26
|
+
if node.get('disabled'):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
# Check if visible
|
|
30
|
+
box = node.get('box')
|
|
31
|
+
if box and not box.get('visible', True):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AriaTreeResult:
|
|
39
|
+
"""
|
|
40
|
+
Result of aria tree generation.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
snapshot: The accessibility tree snapshot
|
|
44
|
+
ref_map: Mapping from ref string to XPath, e.g., {'ref1': '/html/body/div[1]'}
|
|
45
|
+
method: Generation method used ('dom' or 'cdp')
|
|
46
|
+
yaml_str: Pre-generated YAML string (from JavaScript)
|
|
47
|
+
"""
|
|
48
|
+
snapshot: AriaSnapshot
|
|
49
|
+
ref_map: Dict[str, str]
|
|
50
|
+
method: str
|
|
51
|
+
yaml_str: str = '' # Pre-generated YAML from JavaScript
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> Dict:
|
|
54
|
+
"""Convert to dictionary format."""
|
|
55
|
+
return {
|
|
56
|
+
'snapshot': self.snapshot,
|
|
57
|
+
'refMap': self.ref_map,
|
|
58
|
+
'method': self.method
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def to_yaml(self) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Render an aria tree to YAML string.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
YAML string representation of the snapshot
|
|
67
|
+
"""
|
|
68
|
+
# Use pre-generated YAML if available (from JavaScript)
|
|
69
|
+
return self.yaml_str
|
|
70
|
+
|
|
71
|
+
def to_json(self, compact: bool = False) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Render an aria tree to JSON string.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
compact: If True, use compact JSON format (no indentation)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
JSON string representation of the snapshot
|
|
80
|
+
"""
|
|
81
|
+
# Use the original structure directly, no simplification needed
|
|
82
|
+
if compact:
|
|
83
|
+
return json.dumps(self.snapshot['root'], separators=(',', ':'), ensure_ascii=False)
|
|
84
|
+
else:
|
|
85
|
+
return json.dumps(self.snapshot['root'], indent=2, ensure_ascii=False)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _normalize_whitespace(text: str) -> str:
|
|
89
|
+
"""Normalize whitespace in text."""
|
|
90
|
+
if not text:
|
|
91
|
+
return ''
|
|
92
|
+
return ' '.join(text.split())
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _escape_yaml(text: str) -> str:
|
|
96
|
+
"""Escape text for YAML output."""
|
|
97
|
+
if not text:
|
|
98
|
+
return ''
|
|
99
|
+
text = text.replace('\\', '\\\\')
|
|
100
|
+
text = text.replace('"', '\\"')
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
class AriaTreeGenerator(ABC):
|
|
104
|
+
"""Abstract base class for aria tree generators."""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def generate(
|
|
108
|
+
self,
|
|
109
|
+
root_element: Any,
|
|
110
|
+
options: AriaTreeOptions
|
|
111
|
+
) -> AriaTreeResult:
|
|
112
|
+
"""
|
|
113
|
+
Generate an accessibility tree from a DOM element.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
root_element: Root DOM element to start from
|
|
117
|
+
options: Options for tree generation
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
AriaTreeResult containing snapshot, ref_map, and method
|
|
121
|
+
"""
|
|
122
|
+
pass
|
|
123
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DOM-based Aria Tree Generator.
|
|
3
|
+
Uses JavaScript injection to compute accessibility tree from DOM.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from .base import AriaTreeGenerator, AriaTreeResult
|
|
9
|
+
from .types import AriaTreeOptions, AriaSnapshot
|
|
10
|
+
from backend.daemon_server.scripts.script_loader import ScriptLoader
|
|
11
|
+
from webdriver_bidi.local_value import LocalValue as V
|
|
12
|
+
|
|
13
|
+
ARIA_SNAPSHOT_SYMBOL_KEY = "stealth.ariaSnapshot.v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DOMAriaTreeGenerator(AriaTreeGenerator):
|
|
17
|
+
"""
|
|
18
|
+
DOM-based accessibility tree generator.
|
|
19
|
+
|
|
20
|
+
Uses JavaScript to walk the DOM and compute accessibility properties.
|
|
21
|
+
Fast and works with any browser, but may miss some ARIA edge cases.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize the DOM generator."""
|
|
26
|
+
self._tool: Optional[ScriptLoader] = None
|
|
27
|
+
|
|
28
|
+
def generate(
|
|
29
|
+
self,
|
|
30
|
+
root_element: Any,
|
|
31
|
+
options: AriaTreeOptions,
|
|
32
|
+
driver: Any = None
|
|
33
|
+
) -> AriaTreeResult:
|
|
34
|
+
"""
|
|
35
|
+
Generate an accessibility tree from a DOM element using JavaScript.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
root_element: Root DOM element to start from (BiDiElement)
|
|
39
|
+
options: Options for tree generation
|
|
40
|
+
driver: BiDi-based driver instance (required)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
AriaTreeResult containing snapshot, ref_map, and method
|
|
44
|
+
"""
|
|
45
|
+
if driver is None:
|
|
46
|
+
raise ValueError("driver must be provided to DOMAriaTreeGenerator.generate()")
|
|
47
|
+
|
|
48
|
+
# Prepare previousXpathToRef for stable refs (invert the map for O(1) lookup)
|
|
49
|
+
previous_ref_map = options.get('previous_ref_map')
|
|
50
|
+
previous_xpath_to_ref = None
|
|
51
|
+
if previous_ref_map:
|
|
52
|
+
previous_xpath_to_ref = {
|
|
53
|
+
xpath: ref for ref, xpath in previous_ref_map.items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Build options object (AI mode only)
|
|
57
|
+
ai_options = {
|
|
58
|
+
'depth': options.get('depth'),
|
|
59
|
+
'boxes': options.get('boxes', False),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Use ScriptLoader to call the API
|
|
63
|
+
if self._tool is None:
|
|
64
|
+
self._tool = ScriptLoader(
|
|
65
|
+
driver,
|
|
66
|
+
ARIA_SNAPSHOT_SYMBOL_KEY,
|
|
67
|
+
'ariaSnapshot.js'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Build BiDi LocalValue arguments explicitly
|
|
71
|
+
result = self._tool.call(
|
|
72
|
+
'generateAriaSnapshot',
|
|
73
|
+
V.element(root_element.shared_id), # root element
|
|
74
|
+
V.from_value(ai_options), # options dict
|
|
75
|
+
V.from_value(previous_xpath_to_ref), # previousXpathToRef
|
|
76
|
+
V.boolean(True), # generate_yaml=True
|
|
77
|
+
return_value=True
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Extract snapshot, refMap, and YAML from JavaScript result
|
|
81
|
+
snapshot_dict = result.get('snapshot', result)
|
|
82
|
+
yaml_str = result.get('snapshot_yaml', '')
|
|
83
|
+
|
|
84
|
+
return AriaTreeResult(
|
|
85
|
+
snapshot=snapshot_dict,
|
|
86
|
+
ref_map=result.get('refMap', {}),
|
|
87
|
+
method='dom',
|
|
88
|
+
yaml_str=yaml_str
|
|
89
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing_extensions import TypedDict, NotRequired,Optional
|
|
19
|
+
|
|
20
|
+
# Import auto-generated TypedDict types from TypeScript
|
|
21
|
+
# These are automatically generated by: npm run build:types
|
|
22
|
+
from .types_generated import (
|
|
23
|
+
ScrollInfo,
|
|
24
|
+
AriaBox,
|
|
25
|
+
AriaExtraProps,
|
|
26
|
+
AriaNode,
|
|
27
|
+
AriaTreeOptions as GeneratedAriaTreeOptions,
|
|
28
|
+
AriaTreeResult
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Re-export the generated types
|
|
32
|
+
__all__ = [
|
|
33
|
+
'ScrollInfo', 'AriaBox', 'AriaExtraProps', 'AriaNode',
|
|
34
|
+
'AriaTreeOptions', 'AriaSnapshot', 'AriaTreeResult',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Define AriaSnapshot (wrapper for root node)
|
|
39
|
+
class AriaSnapshot(TypedDict):
|
|
40
|
+
"""
|
|
41
|
+
Complete accessibility snapshot.
|
|
42
|
+
|
|
43
|
+
Structure: { root: AriaNode }
|
|
44
|
+
"""
|
|
45
|
+
root: AriaNode
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Extend AriaTreeOptions with additional Python-specific fields
|
|
49
|
+
class AriaTreeOptions(GeneratedAriaTreeOptions, total=False):
|
|
50
|
+
"""
|
|
51
|
+
Options for generating aria tree for AI/LLM consumption.
|
|
52
|
+
|
|
53
|
+
Extends the auto-generated AriaTreeOptions with Python-specific options.
|
|
54
|
+
"""
|
|
55
|
+
ref_prefix: str
|
|
56
|
+
depth: Optional[int]
|
|
57
|
+
previous_ref_map: dict[str, str]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing_extensions import Dict, List, Literal, NotRequired, TypedDict, Union
|
|
2
|
+
|
|
3
|
+
class ScrollInfo(TypedDict):
|
|
4
|
+
scrollX: float
|
|
5
|
+
scrollY: float
|
|
6
|
+
scrollWidth: float
|
|
7
|
+
scrollHeight: float
|
|
8
|
+
clientWidth: float
|
|
9
|
+
clientHeight: float
|
|
10
|
+
scrollable: bool
|
|
11
|
+
|
|
12
|
+
class AriaBox(TypedDict):
|
|
13
|
+
visible: bool
|
|
14
|
+
inline: bool
|
|
15
|
+
cursor: Union[None,str]
|
|
16
|
+
x: NotRequired[float]
|
|
17
|
+
y: NotRequired[float]
|
|
18
|
+
width: NotRequired[float]
|
|
19
|
+
height: NotRequired[float]
|
|
20
|
+
|
|
21
|
+
class AriaExtraProps(TypedDict):
|
|
22
|
+
url: NotRequired[str]
|
|
23
|
+
placeholder: NotRequired[str]
|
|
24
|
+
src: NotRequired[str]
|
|
25
|
+
size: NotRequired[str]
|
|
26
|
+
|
|
27
|
+
class AriaNode(TypedDict):
|
|
28
|
+
role: str
|
|
29
|
+
name: str
|
|
30
|
+
children: List[Union[str,'AriaNode']]
|
|
31
|
+
props: AriaExtraProps
|
|
32
|
+
box: AriaBox
|
|
33
|
+
receivesPointerEvents: bool
|
|
34
|
+
ref: NotRequired[str]
|
|
35
|
+
scroll: NotRequired[ScrollInfo]
|
|
36
|
+
active: NotRequired[bool]
|
|
37
|
+
checked: NotRequired[Union[Literal[False],Literal[True],Literal["mixed"]]]
|
|
38
|
+
disabled: NotRequired[bool]
|
|
39
|
+
expanded: NotRequired[bool]
|
|
40
|
+
level: NotRequired[float]
|
|
41
|
+
pressed: NotRequired[Union[Literal[False],Literal[True],Literal["mixed"]]]
|
|
42
|
+
selected: NotRequired[bool]
|
|
43
|
+
value: NotRequired[float]
|
|
44
|
+
min: NotRequired[float]
|
|
45
|
+
max: NotRequired[float]
|
|
46
|
+
modal: NotRequired[bool]
|
|
47
|
+
|
|
48
|
+
class AriaTreeOptions(TypedDict):
|
|
49
|
+
refPrefix: NotRequired[str]
|
|
50
|
+
doNotRenderActive: NotRequired[bool]
|
|
51
|
+
depth: NotRequired[float]
|
|
52
|
+
boxes: NotRequired[bool]
|
|
53
|
+
|
|
54
|
+
class Ts2Py_RiRAjt1ZVF(TypedDict):
|
|
55
|
+
root: AriaNode
|
|
56
|
+
|
|
57
|
+
class AriaTreeResult(TypedDict):
|
|
58
|
+
snapshot: Ts2Py_RiRAjt1ZVF
|
|
59
|
+
refMap: Dict[str,str]
|
|
60
|
+
snapshot_yaml: NotRequired[str]
|