sentienceapi 0.90.16__py3-none-any.whl → 0.92.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sentienceapi might be problematic. Click here for more details.
- sentience/__init__.py +14 -5
- sentience/action_executor.py +215 -0
- sentience/actions.py +408 -25
- sentience/agent.py +802 -293
- sentience/agent_config.py +3 -0
- sentience/async_api.py +83 -1142
- sentience/base_agent.py +95 -0
- sentience/browser.py +484 -1
- sentience/browser_evaluator.py +299 -0
- sentience/cloud_tracing.py +457 -33
- sentience/conversational_agent.py +77 -43
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +117 -289
- sentience/extension/injected_api.js +799 -1374
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.js +190 -396
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +74 -52
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +60 -1
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +1 -1
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +102 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +291 -38
- sentience/snapshot_diff.py +141 -0
- sentience/text_search.py +119 -5
- sentience/trace_event_builder.py +129 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +117 -14
- sentience/tracer_factory.py +119 -6
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/utils/element.py +257 -0
- sentience/utils/formatting.py +59 -0
- sentience/utils.py +1 -1
- sentience/visual_agent.py +2056 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +2 -1
- sentienceapi-0.92.2.dist-info/RECORD +65 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/top_level.txt +0 -0
sentience/base_agent.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
"""
|
|
2
4
|
BaseAgent: Abstract base class for all Sentience agents
|
|
3
5
|
Defines the interface that all agent implementations must follow
|
|
@@ -99,3 +101,96 @@ class BaseAgent(ABC):
|
|
|
99
101
|
>>> # filtered now contains only relevant elements
|
|
100
102
|
"""
|
|
101
103
|
return snapshot.elements
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class BaseAgentAsync(ABC):
|
|
107
|
+
"""
|
|
108
|
+
Abstract base class for all async Sentience agents.
|
|
109
|
+
|
|
110
|
+
Provides a standard interface for:
|
|
111
|
+
- Executing natural language goals (act)
|
|
112
|
+
- Tracking execution history
|
|
113
|
+
- Monitoring token usage
|
|
114
|
+
- Filtering elements based on goals
|
|
115
|
+
|
|
116
|
+
Subclasses must implement:
|
|
117
|
+
- act(): Execute a natural language goal (async)
|
|
118
|
+
- get_history(): Return execution history
|
|
119
|
+
- get_token_stats(): Return token usage statistics
|
|
120
|
+
- clear_history(): Reset history and token counters
|
|
121
|
+
|
|
122
|
+
Subclasses can override:
|
|
123
|
+
- filter_elements(): Customize element filtering logic
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
async def act(self, goal: str, **kwargs) -> AgentActionResult:
|
|
128
|
+
"""
|
|
129
|
+
Execute a natural language goal using the agent (async).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
goal: Natural language instruction (e.g., "Click the login button")
|
|
133
|
+
**kwargs: Additional parameters (implementation-specific)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
AgentActionResult with execution details
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
RuntimeError: If execution fails after retries
|
|
140
|
+
"""
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def get_history(self) -> list[ActionHistory]:
|
|
145
|
+
"""
|
|
146
|
+
Get the execution history of all actions taken.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of ActionHistory entries
|
|
150
|
+
"""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def get_token_stats(self) -> TokenStats:
|
|
155
|
+
"""
|
|
156
|
+
Get token usage statistics for the agent session.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
TokenStats with cumulative token counts
|
|
160
|
+
"""
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def clear_history(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Clear execution history and reset token counters.
|
|
167
|
+
|
|
168
|
+
This resets the agent to a clean state.
|
|
169
|
+
"""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def filter_elements(self, snapshot: Snapshot, goal: str | None = None) -> list[Element]:
|
|
173
|
+
"""
|
|
174
|
+
Filter elements from a snapshot based on goal context.
|
|
175
|
+
|
|
176
|
+
Default implementation returns all elements unchanged.
|
|
177
|
+
Subclasses can override to implement custom filtering logic
|
|
178
|
+
such as:
|
|
179
|
+
- Removing irrelevant elements based on goal keywords
|
|
180
|
+
- Boosting importance of matching elements
|
|
181
|
+
- Filtering by role, size, or visual properties
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
snapshot: Current page snapshot
|
|
185
|
+
goal: User's goal (can inform filtering strategy)
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Filtered list of elements (default: all elements)
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
>>> agent = SentienceAgentAsync(browser, llm)
|
|
192
|
+
>>> snap = await snapshot_async(browser)
|
|
193
|
+
>>> filtered = agent.filter_elements(snap, goal="Click login")
|
|
194
|
+
>>> # filtered now contains only relevant elements
|
|
195
|
+
"""
|
|
196
|
+
return snapshot.elements
|
sentience/browser.py
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
Playwright browser harness with extension loading
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import os
|
|
6
7
|
import shutil
|
|
7
8
|
import tempfile
|
|
8
9
|
import time
|
|
9
10
|
from pathlib import Path
|
|
11
|
+
from typing import Optional, Union
|
|
10
12
|
from urllib.parse import urlparse
|
|
11
13
|
|
|
14
|
+
from playwright.async_api import BrowserContext as AsyncBrowserContext
|
|
15
|
+
from playwright.async_api import Page as AsyncPage
|
|
16
|
+
from playwright.async_api import Playwright as AsyncPlaywright
|
|
17
|
+
from playwright.async_api import async_playwright
|
|
12
18
|
from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright
|
|
13
19
|
|
|
14
20
|
from sentience._extension_loader import find_extension_path
|
|
@@ -16,7 +22,7 @@ from sentience.models import ProxyConfig, StorageState, Viewport
|
|
|
16
22
|
|
|
17
23
|
# Import stealth for bot evasion (optional - graceful fallback if not available)
|
|
18
24
|
try:
|
|
19
|
-
from playwright_stealth import stealth_sync
|
|
25
|
+
from playwright_stealth import stealth_async, stealth_sync
|
|
20
26
|
|
|
21
27
|
STEALTH_AVAILABLE = True
|
|
22
28
|
except ImportError:
|
|
@@ -37,6 +43,7 @@ class SentienceBrowser:
|
|
|
37
43
|
record_video_dir: str | Path | None = None,
|
|
38
44
|
record_video_size: dict[str, int] | None = None,
|
|
39
45
|
viewport: Viewport | dict[str, int] | None = None,
|
|
46
|
+
device_scale_factor: float | None = None,
|
|
40
47
|
):
|
|
41
48
|
"""
|
|
42
49
|
Initialize Sentience browser
|
|
@@ -109,6 +116,9 @@ class SentienceBrowser:
|
|
|
109
116
|
else:
|
|
110
117
|
self.viewport = viewport
|
|
111
118
|
|
|
119
|
+
# Device scale factor for high-DPI emulation
|
|
120
|
+
self.device_scale_factor = device_scale_factor
|
|
121
|
+
|
|
112
122
|
self.playwright: Playwright | None = None
|
|
113
123
|
self.context: BrowserContext | None = None
|
|
114
124
|
self.page: Page | None = None
|
|
@@ -211,6 +221,10 @@ class SentienceBrowser:
|
|
|
211
221
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
# Add device scale factor if configured
|
|
225
|
+
if self.device_scale_factor is not None:
|
|
226
|
+
launch_params["device_scale_factor"] = self.device_scale_factor
|
|
227
|
+
|
|
214
228
|
# Add proxy if configured
|
|
215
229
|
if proxy_config:
|
|
216
230
|
launch_params["proxy"] = proxy_config.to_playwright_dict()
|
|
@@ -574,3 +588,472 @@ class SentienceBrowser:
|
|
|
574
588
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
575
589
|
"""Context manager exit"""
|
|
576
590
|
self.close()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class AsyncSentienceBrowser:
|
|
594
|
+
"""Async version of SentienceBrowser for use in asyncio contexts."""
|
|
595
|
+
|
|
596
|
+
def __init__(
|
|
597
|
+
self,
|
|
598
|
+
api_key: str | None = None,
|
|
599
|
+
api_url: str | None = None,
|
|
600
|
+
headless: bool | None = None,
|
|
601
|
+
proxy: str | None = None,
|
|
602
|
+
user_data_dir: str | Path | None = None,
|
|
603
|
+
storage_state: str | Path | StorageState | dict | None = None,
|
|
604
|
+
record_video_dir: str | Path | None = None,
|
|
605
|
+
record_video_size: dict[str, int] | None = None,
|
|
606
|
+
viewport: Viewport | dict[str, int] | None = None,
|
|
607
|
+
device_scale_factor: float | None = None,
|
|
608
|
+
):
|
|
609
|
+
"""
|
|
610
|
+
Initialize Async Sentience browser
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
api_key: Optional API key for server-side processing (Pro/Enterprise tiers)
|
|
614
|
+
If None, uses free tier (local extension only)
|
|
615
|
+
api_url: Server URL for API calls (defaults to https://api.sentienceapi.com if api_key provided)
|
|
616
|
+
headless: Whether to run in headless mode. If None, defaults to True in CI, False otherwise
|
|
617
|
+
proxy: Optional proxy server URL (e.g., 'http://user:pass@proxy.example.com:8080')
|
|
618
|
+
user_data_dir: Optional path to user data directory for persistent sessions
|
|
619
|
+
storage_state: Optional storage state to inject (cookies + localStorage)
|
|
620
|
+
record_video_dir: Optional directory path to save video recordings
|
|
621
|
+
record_video_size: Optional video resolution as dict with 'width' and 'height' keys
|
|
622
|
+
viewport: Optional viewport size as Viewport object or dict with 'width' and 'height' keys.
|
|
623
|
+
Examples: Viewport(width=1280, height=800) (default)
|
|
624
|
+
Viewport(width=1920, height=1080) (Full HD)
|
|
625
|
+
{"width": 1280, "height": 800} (dict also supported)
|
|
626
|
+
If None, defaults to Viewport(width=1280, height=800).
|
|
627
|
+
device_scale_factor: Optional device scale factor to emulate high-DPI (Retina) screens.
|
|
628
|
+
Examples: 1.0 (default, standard DPI)
|
|
629
|
+
2.0 (Retina/high-DPI, like MacBook Pro)
|
|
630
|
+
3.0 (very high DPI)
|
|
631
|
+
If None, defaults to 1.0 (standard DPI).
|
|
632
|
+
"""
|
|
633
|
+
self.api_key = api_key
|
|
634
|
+
# Only set api_url if api_key is provided, otherwise None (free tier)
|
|
635
|
+
if self.api_key and not api_url:
|
|
636
|
+
self.api_url = "https://api.sentienceapi.com"
|
|
637
|
+
else:
|
|
638
|
+
self.api_url = api_url
|
|
639
|
+
|
|
640
|
+
# Determine headless mode
|
|
641
|
+
if headless is None:
|
|
642
|
+
# Default to False for local dev, True for CI
|
|
643
|
+
self.headless = os.environ.get("CI", "").lower() == "true"
|
|
644
|
+
else:
|
|
645
|
+
self.headless = headless
|
|
646
|
+
|
|
647
|
+
# Support proxy from argument or environment variable
|
|
648
|
+
self.proxy = proxy or os.environ.get("SENTIENCE_PROXY")
|
|
649
|
+
|
|
650
|
+
# Auth injection support
|
|
651
|
+
self.user_data_dir = user_data_dir
|
|
652
|
+
self.storage_state = storage_state
|
|
653
|
+
|
|
654
|
+
# Video recording support
|
|
655
|
+
self.record_video_dir = record_video_dir
|
|
656
|
+
self.record_video_size = record_video_size or {"width": 1280, "height": 800}
|
|
657
|
+
|
|
658
|
+
# Viewport configuration - convert dict to Viewport if needed
|
|
659
|
+
if viewport is None:
|
|
660
|
+
self.viewport = Viewport(width=1280, height=800)
|
|
661
|
+
elif isinstance(viewport, dict):
|
|
662
|
+
self.viewport = Viewport(width=viewport["width"], height=viewport["height"])
|
|
663
|
+
else:
|
|
664
|
+
self.viewport = viewport
|
|
665
|
+
|
|
666
|
+
# Device scale factor for high-DPI emulation
|
|
667
|
+
self.device_scale_factor = device_scale_factor
|
|
668
|
+
|
|
669
|
+
self.playwright: AsyncPlaywright | None = None
|
|
670
|
+
self.context: AsyncBrowserContext | None = None
|
|
671
|
+
self.page: AsyncPage | None = None
|
|
672
|
+
self._extension_path: str | None = None
|
|
673
|
+
|
|
674
|
+
def _parse_proxy(self, proxy_string: str) -> ProxyConfig | None:
|
|
675
|
+
"""
|
|
676
|
+
Parse proxy connection string into ProxyConfig.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
proxy_string: Proxy URL (e.g., 'http://user:pass@proxy.example.com:8080')
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
ProxyConfig object or None if invalid
|
|
683
|
+
"""
|
|
684
|
+
if not proxy_string:
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
parsed = urlparse(proxy_string)
|
|
689
|
+
|
|
690
|
+
# Validate scheme
|
|
691
|
+
if parsed.scheme not in ("http", "https", "socks5"):
|
|
692
|
+
print(f"⚠️ [Sentience] Unsupported proxy scheme: {parsed.scheme}")
|
|
693
|
+
print(" Supported: http, https, socks5")
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
# Validate host and port
|
|
697
|
+
if not parsed.hostname or not parsed.port:
|
|
698
|
+
print("⚠️ [Sentience] Proxy URL must include hostname and port")
|
|
699
|
+
print(" Expected format: http://username:password@host:port")
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
# Build server URL
|
|
703
|
+
server = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
|
|
704
|
+
|
|
705
|
+
# Create ProxyConfig with optional credentials
|
|
706
|
+
return ProxyConfig(
|
|
707
|
+
server=server,
|
|
708
|
+
username=parsed.username if parsed.username else None,
|
|
709
|
+
password=parsed.password if parsed.password else None,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
except Exception as e:
|
|
713
|
+
print(f"⚠️ [Sentience] Invalid proxy configuration: {e}")
|
|
714
|
+
print(" Expected format: http://username:password@host:port")
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
async def start(self) -> None:
|
|
718
|
+
"""Launch browser with extension loaded (async)"""
|
|
719
|
+
# Get extension source path using shared utility
|
|
720
|
+
extension_source = find_extension_path()
|
|
721
|
+
|
|
722
|
+
# Create temporary extension bundle
|
|
723
|
+
self._extension_path = tempfile.mkdtemp(prefix="sentience-ext-")
|
|
724
|
+
shutil.copytree(extension_source, self._extension_path, dirs_exist_ok=True)
|
|
725
|
+
|
|
726
|
+
self.playwright = await async_playwright().start()
|
|
727
|
+
|
|
728
|
+
# Build launch arguments
|
|
729
|
+
args = [
|
|
730
|
+
f"--disable-extensions-except={self._extension_path}",
|
|
731
|
+
f"--load-extension={self._extension_path}",
|
|
732
|
+
"--disable-blink-features=AutomationControlled",
|
|
733
|
+
"--no-sandbox",
|
|
734
|
+
"--disable-infobars",
|
|
735
|
+
"--disable-features=WebRtcHideLocalIpsWithMdns",
|
|
736
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
737
|
+
]
|
|
738
|
+
|
|
739
|
+
if self.headless:
|
|
740
|
+
args.append("--headless=new")
|
|
741
|
+
|
|
742
|
+
# Parse proxy configuration if provided
|
|
743
|
+
proxy_config = self._parse_proxy(self.proxy) if self.proxy else None
|
|
744
|
+
|
|
745
|
+
# Handle User Data Directory
|
|
746
|
+
if self.user_data_dir:
|
|
747
|
+
user_data_dir = str(self.user_data_dir)
|
|
748
|
+
Path(user_data_dir).mkdir(parents=True, exist_ok=True)
|
|
749
|
+
else:
|
|
750
|
+
user_data_dir = ""
|
|
751
|
+
|
|
752
|
+
# Build launch_persistent_context parameters
|
|
753
|
+
launch_params = {
|
|
754
|
+
"user_data_dir": user_data_dir,
|
|
755
|
+
"headless": False,
|
|
756
|
+
"args": args,
|
|
757
|
+
"viewport": {"width": self.viewport.width, "height": self.viewport.height},
|
|
758
|
+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
# Add device scale factor if configured
|
|
762
|
+
if self.device_scale_factor is not None:
|
|
763
|
+
launch_params["device_scale_factor"] = self.device_scale_factor
|
|
764
|
+
|
|
765
|
+
# Add proxy if configured
|
|
766
|
+
if proxy_config:
|
|
767
|
+
launch_params["proxy"] = proxy_config.to_playwright_dict()
|
|
768
|
+
launch_params["ignore_https_errors"] = True
|
|
769
|
+
print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
|
|
770
|
+
|
|
771
|
+
# Add video recording if configured
|
|
772
|
+
if self.record_video_dir:
|
|
773
|
+
video_dir = Path(self.record_video_dir)
|
|
774
|
+
video_dir.mkdir(parents=True, exist_ok=True)
|
|
775
|
+
launch_params["record_video_dir"] = str(video_dir)
|
|
776
|
+
launch_params["record_video_size"] = self.record_video_size
|
|
777
|
+
print(f"🎥 [Sentience] Recording video to: {video_dir}")
|
|
778
|
+
print(
|
|
779
|
+
f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# Launch persistent context
|
|
783
|
+
self.context = await self.playwright.chromium.launch_persistent_context(**launch_params)
|
|
784
|
+
|
|
785
|
+
self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()
|
|
786
|
+
|
|
787
|
+
# Inject storage state if provided
|
|
788
|
+
if self.storage_state:
|
|
789
|
+
await self._inject_storage_state(self.storage_state)
|
|
790
|
+
|
|
791
|
+
# Apply stealth if available
|
|
792
|
+
if STEALTH_AVAILABLE:
|
|
793
|
+
await stealth_async(self.page)
|
|
794
|
+
|
|
795
|
+
# Wait a moment for extension to initialize
|
|
796
|
+
await asyncio.sleep(0.5)
|
|
797
|
+
|
|
798
|
+
async def goto(self, url: str) -> None:
|
|
799
|
+
"""Navigate to a URL and ensure extension is ready (async)"""
|
|
800
|
+
if not self.page:
|
|
801
|
+
raise RuntimeError("Browser not started. Call await start() first.")
|
|
802
|
+
|
|
803
|
+
await self.page.goto(url, wait_until="domcontentloaded")
|
|
804
|
+
|
|
805
|
+
# Wait for extension to be ready
|
|
806
|
+
if not await self._wait_for_extension():
|
|
807
|
+
try:
|
|
808
|
+
diag = await self.page.evaluate(
|
|
809
|
+
"""() => ({
|
|
810
|
+
sentience_defined: typeof window.sentience !== 'undefined',
|
|
811
|
+
registry_defined: typeof window.sentience_registry !== 'undefined',
|
|
812
|
+
snapshot_defined: window.sentience && typeof window.sentience.snapshot === 'function',
|
|
813
|
+
extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
|
|
814
|
+
url: window.location.href
|
|
815
|
+
})"""
|
|
816
|
+
)
|
|
817
|
+
except Exception as e:
|
|
818
|
+
diag = f"Failed to get diagnostics: {str(e)}"
|
|
819
|
+
|
|
820
|
+
raise RuntimeError(
|
|
821
|
+
"Extension failed to load after navigation. Make sure:\n"
|
|
822
|
+
"1. Extension is built (cd sentience-chrome && ./build.sh)\n"
|
|
823
|
+
"2. All files are present (manifest.json, content.js, injected_api.js, pkg/)\n"
|
|
824
|
+
"3. Check browser console for errors (run with headless=False to see console)\n"
|
|
825
|
+
f"4. Extension path: {self._extension_path}\n"
|
|
826
|
+
f"5. Diagnostic info: {diag}"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
async def _inject_storage_state(self, storage_state: str | Path | StorageState | dict) -> None:
|
|
830
|
+
"""Inject storage state (cookies + localStorage) into browser context (async)"""
|
|
831
|
+
import json
|
|
832
|
+
|
|
833
|
+
# Load storage state
|
|
834
|
+
if isinstance(storage_state, (str, Path)):
|
|
835
|
+
with open(storage_state, encoding="utf-8") as f:
|
|
836
|
+
state_dict = json.load(f)
|
|
837
|
+
state = StorageState.from_dict(state_dict)
|
|
838
|
+
elif isinstance(storage_state, StorageState):
|
|
839
|
+
state = storage_state
|
|
840
|
+
elif isinstance(storage_state, dict):
|
|
841
|
+
state = StorageState.from_dict(storage_state)
|
|
842
|
+
else:
|
|
843
|
+
raise ValueError(
|
|
844
|
+
f"Invalid storage_state type: {type(storage_state)}. "
|
|
845
|
+
"Expected str, Path, StorageState, or dict."
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Inject cookies
|
|
849
|
+
if state.cookies:
|
|
850
|
+
playwright_cookies = []
|
|
851
|
+
for cookie in state.cookies:
|
|
852
|
+
cookie_dict = cookie.model_dump()
|
|
853
|
+
playwright_cookie = {
|
|
854
|
+
"name": cookie_dict["name"],
|
|
855
|
+
"value": cookie_dict["value"],
|
|
856
|
+
"domain": cookie_dict["domain"],
|
|
857
|
+
"path": cookie_dict["path"],
|
|
858
|
+
}
|
|
859
|
+
if cookie_dict.get("expires"):
|
|
860
|
+
playwright_cookie["expires"] = cookie_dict["expires"]
|
|
861
|
+
if cookie_dict.get("httpOnly"):
|
|
862
|
+
playwright_cookie["httpOnly"] = cookie_dict["httpOnly"]
|
|
863
|
+
if cookie_dict.get("secure"):
|
|
864
|
+
playwright_cookie["secure"] = cookie_dict["secure"]
|
|
865
|
+
if cookie_dict.get("sameSite"):
|
|
866
|
+
playwright_cookie["sameSite"] = cookie_dict["sameSite"]
|
|
867
|
+
playwright_cookies.append(playwright_cookie)
|
|
868
|
+
|
|
869
|
+
await self.context.add_cookies(playwright_cookies)
|
|
870
|
+
print(f"✅ [Sentience] Injected {len(state.cookies)} cookie(s)")
|
|
871
|
+
|
|
872
|
+
# Inject LocalStorage
|
|
873
|
+
if state.origins:
|
|
874
|
+
for origin_data in state.origins:
|
|
875
|
+
origin = origin_data.origin
|
|
876
|
+
if not origin:
|
|
877
|
+
continue
|
|
878
|
+
|
|
879
|
+
try:
|
|
880
|
+
await self.page.goto(origin, wait_until="domcontentloaded", timeout=10000)
|
|
881
|
+
|
|
882
|
+
if origin_data.localStorage:
|
|
883
|
+
localStorage_dict = {
|
|
884
|
+
item.name: item.value for item in origin_data.localStorage
|
|
885
|
+
}
|
|
886
|
+
await self.page.evaluate(
|
|
887
|
+
"""(localStorage_data) => {
|
|
888
|
+
for (const [key, value] of Object.entries(localStorage_data)) {
|
|
889
|
+
localStorage.setItem(key, value);
|
|
890
|
+
}
|
|
891
|
+
}""",
|
|
892
|
+
localStorage_dict,
|
|
893
|
+
)
|
|
894
|
+
print(
|
|
895
|
+
f"✅ [Sentience] Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
|
|
896
|
+
)
|
|
897
|
+
except Exception as e:
|
|
898
|
+
print(f"⚠️ [Sentience] Failed to inject localStorage for {origin}: {e}")
|
|
899
|
+
|
|
900
|
+
async def _wait_for_extension(self, timeout_sec: float = 5.0) -> bool:
|
|
901
|
+
"""Poll for window.sentience to be available (async)"""
|
|
902
|
+
start_time = time.time()
|
|
903
|
+
last_error = None
|
|
904
|
+
|
|
905
|
+
while time.time() - start_time < timeout_sec:
|
|
906
|
+
try:
|
|
907
|
+
result = await self.page.evaluate(
|
|
908
|
+
"""() => {
|
|
909
|
+
if (typeof window.sentience === 'undefined') {
|
|
910
|
+
return { ready: false, reason: 'window.sentience undefined' };
|
|
911
|
+
}
|
|
912
|
+
if (window.sentience._wasmModule === null) {
|
|
913
|
+
return { ready: false, reason: 'WASM module not fully loaded' };
|
|
914
|
+
}
|
|
915
|
+
return { ready: true };
|
|
916
|
+
}
|
|
917
|
+
"""
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
if isinstance(result, dict):
|
|
921
|
+
if result.get("ready"):
|
|
922
|
+
return True
|
|
923
|
+
last_error = result.get("reason", "Unknown error")
|
|
924
|
+
except Exception as e:
|
|
925
|
+
last_error = f"Evaluation error: {str(e)}"
|
|
926
|
+
|
|
927
|
+
await asyncio.sleep(0.3)
|
|
928
|
+
|
|
929
|
+
if last_error:
|
|
930
|
+
import warnings
|
|
931
|
+
|
|
932
|
+
warnings.warn(f"Extension wait timeout. Last status: {last_error}")
|
|
933
|
+
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
async def close(self, output_path: str | Path | None = None) -> str | None:
|
|
937
|
+
"""
|
|
938
|
+
Close browser and cleanup (async)
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
output_path: Optional path to rename the video file to
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
Path to video file if recording was enabled, None otherwise
|
|
945
|
+
"""
|
|
946
|
+
temp_video_path = None
|
|
947
|
+
|
|
948
|
+
if self.record_video_dir:
|
|
949
|
+
try:
|
|
950
|
+
if self.page and self.page.video:
|
|
951
|
+
temp_video_path = await self.page.video.path()
|
|
952
|
+
elif self.context:
|
|
953
|
+
for page in self.context.pages:
|
|
954
|
+
if page.video:
|
|
955
|
+
temp_video_path = await page.video.path()
|
|
956
|
+
break
|
|
957
|
+
except Exception:
|
|
958
|
+
pass
|
|
959
|
+
|
|
960
|
+
if self.context:
|
|
961
|
+
await self.context.close()
|
|
962
|
+
self.context = None
|
|
963
|
+
|
|
964
|
+
if self.playwright:
|
|
965
|
+
await self.playwright.stop()
|
|
966
|
+
self.playwright = None
|
|
967
|
+
|
|
968
|
+
if self._extension_path and os.path.exists(self._extension_path):
|
|
969
|
+
shutil.rmtree(self._extension_path)
|
|
970
|
+
|
|
971
|
+
# Clear page reference after closing context
|
|
972
|
+
self.page = None
|
|
973
|
+
|
|
974
|
+
final_path = temp_video_path
|
|
975
|
+
if temp_video_path and output_path and os.path.exists(temp_video_path):
|
|
976
|
+
try:
|
|
977
|
+
output_path = str(output_path)
|
|
978
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
shutil.move(temp_video_path, output_path)
|
|
980
|
+
final_path = output_path
|
|
981
|
+
except Exception as e:
|
|
982
|
+
import warnings
|
|
983
|
+
|
|
984
|
+
warnings.warn(f"Failed to rename video file: {e}")
|
|
985
|
+
final_path = temp_video_path
|
|
986
|
+
|
|
987
|
+
return final_path
|
|
988
|
+
|
|
989
|
+
async def __aenter__(self):
|
|
990
|
+
"""Async context manager entry"""
|
|
991
|
+
await self.start()
|
|
992
|
+
return self
|
|
993
|
+
|
|
994
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
995
|
+
"""Async context manager exit"""
|
|
996
|
+
await self.close()
|
|
997
|
+
|
|
998
|
+
@classmethod
|
|
999
|
+
async def from_existing(
|
|
1000
|
+
cls,
|
|
1001
|
+
context: AsyncBrowserContext,
|
|
1002
|
+
api_key: str | None = None,
|
|
1003
|
+
api_url: str | None = None,
|
|
1004
|
+
) -> "AsyncSentienceBrowser":
|
|
1005
|
+
"""
|
|
1006
|
+
Create AsyncSentienceBrowser from an existing Playwright BrowserContext.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
context: Existing Playwright BrowserContext
|
|
1010
|
+
api_key: Optional API key for server-side processing
|
|
1011
|
+
api_url: Optional API URL
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
AsyncSentienceBrowser instance configured to use the existing context
|
|
1015
|
+
"""
|
|
1016
|
+
instance = cls(api_key=api_key, api_url=api_url)
|
|
1017
|
+
instance.context = context
|
|
1018
|
+
pages = context.pages
|
|
1019
|
+
instance.page = pages[0] if pages else await context.new_page()
|
|
1020
|
+
|
|
1021
|
+
# Apply stealth if available
|
|
1022
|
+
if STEALTH_AVAILABLE:
|
|
1023
|
+
await stealth_async(instance.page)
|
|
1024
|
+
|
|
1025
|
+
# Wait for extension to be ready
|
|
1026
|
+
await asyncio.sleep(0.5)
|
|
1027
|
+
|
|
1028
|
+
return instance
|
|
1029
|
+
|
|
1030
|
+
@classmethod
|
|
1031
|
+
async def from_page(
|
|
1032
|
+
cls,
|
|
1033
|
+
page: AsyncPage,
|
|
1034
|
+
api_key: str | None = None,
|
|
1035
|
+
api_url: str | None = None,
|
|
1036
|
+
) -> "AsyncSentienceBrowser":
|
|
1037
|
+
"""
|
|
1038
|
+
Create AsyncSentienceBrowser from an existing Playwright Page.
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
page: Existing Playwright Page
|
|
1042
|
+
api_key: Optional API key for server-side processing
|
|
1043
|
+
api_url: Optional API URL
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
AsyncSentienceBrowser instance configured to use the existing page
|
|
1047
|
+
"""
|
|
1048
|
+
instance = cls(api_key=api_key, api_url=api_url)
|
|
1049
|
+
instance.page = page
|
|
1050
|
+
instance.context = page.context
|
|
1051
|
+
|
|
1052
|
+
# Apply stealth if available
|
|
1053
|
+
if STEALTH_AVAILABLE:
|
|
1054
|
+
await stealth_async(instance.page)
|
|
1055
|
+
|
|
1056
|
+
# Wait for extension to be ready
|
|
1057
|
+
await asyncio.sleep(0.5)
|
|
1058
|
+
|
|
1059
|
+
return instance
|