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.

Files changed (61) hide show
  1. sentience/__init__.py +14 -5
  2. sentience/action_executor.py +215 -0
  3. sentience/actions.py +408 -25
  4. sentience/agent.py +802 -293
  5. sentience/agent_config.py +3 -0
  6. sentience/async_api.py +83 -1142
  7. sentience/base_agent.py +95 -0
  8. sentience/browser.py +484 -1
  9. sentience/browser_evaluator.py +299 -0
  10. sentience/cloud_tracing.py +457 -33
  11. sentience/conversational_agent.py +77 -43
  12. sentience/element_filter.py +136 -0
  13. sentience/expect.py +98 -2
  14. sentience/extension/background.js +56 -185
  15. sentience/extension/content.js +117 -289
  16. sentience/extension/injected_api.js +799 -1374
  17. sentience/extension/manifest.json +1 -1
  18. sentience/extension/pkg/sentience_core.js +190 -396
  19. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  20. sentience/extension/release.json +47 -47
  21. sentience/formatting.py +9 -53
  22. sentience/inspector.py +183 -1
  23. sentience/llm_interaction_handler.py +191 -0
  24. sentience/llm_provider.py +74 -52
  25. sentience/llm_provider_utils.py +120 -0
  26. sentience/llm_response_builder.py +153 -0
  27. sentience/models.py +60 -1
  28. sentience/overlay.py +109 -2
  29. sentience/protocols.py +228 -0
  30. sentience/query.py +1 -1
  31. sentience/read.py +95 -3
  32. sentience/recorder.py +223 -3
  33. sentience/schemas/trace_v1.json +102 -9
  34. sentience/screenshot.py +48 -2
  35. sentience/sentience_methods.py +86 -0
  36. sentience/snapshot.py +291 -38
  37. sentience/snapshot_diff.py +141 -0
  38. sentience/text_search.py +119 -5
  39. sentience/trace_event_builder.py +129 -0
  40. sentience/trace_file_manager.py +197 -0
  41. sentience/trace_indexing/index_schema.py +95 -7
  42. sentience/trace_indexing/indexer.py +117 -14
  43. sentience/tracer_factory.py +119 -6
  44. sentience/tracing.py +172 -8
  45. sentience/utils/__init__.py +40 -0
  46. sentience/utils/browser.py +46 -0
  47. sentience/utils/element.py +257 -0
  48. sentience/utils/formatting.py +59 -0
  49. sentience/utils.py +1 -1
  50. sentience/visual_agent.py +2056 -0
  51. sentience/wait.py +68 -2
  52. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/METADATA +2 -1
  53. sentienceapi-0.92.2.dist-info/RECORD +65 -0
  54. sentience/extension/test-content.js +0 -4
  55. sentienceapi-0.90.16.dist-info/RECORD +0 -50
  56. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/WHEEL +0 -0
  57. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/entry_points.txt +0 -0
  58. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE +0 -0
  59. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE-APACHE +0 -0
  60. {sentienceapi-0.90.16.dist-info → sentienceapi-0.92.2.dist-info}/licenses/LICENSE-MIT +0 -0
  61. {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