sentienceapi 0.95.0__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 (82) hide show
  1. sentience/__init__.py +253 -0
  2. sentience/_extension_loader.py +195 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +1020 -0
  5. sentience/agent.py +1181 -0
  6. sentience/agent_config.py +46 -0
  7. sentience/agent_runtime.py +424 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +108 -0
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +343 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +427 -0
  21. sentience/base_agent.py +196 -0
  22. sentience/browser.py +1215 -0
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cli.py +130 -0
  26. sentience/cloud_tracing.py +807 -0
  27. sentience/constants.py +6 -0
  28. sentience/conversational_agent.py +543 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +188 -0
  31. sentience/extension/background.js +104 -0
  32. sentience/extension/content.js +161 -0
  33. sentience/extension/injected_api.js +914 -0
  34. sentience/extension/manifest.json +36 -0
  35. sentience/extension/pkg/sentience_core.d.ts +51 -0
  36. sentience/extension/pkg/sentience_core.js +323 -0
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  39. sentience/extension/release.json +115 -0
  40. sentience/formatting.py +15 -0
  41. sentience/generator.py +202 -0
  42. sentience/inspector.py +367 -0
  43. sentience/llm_interaction_handler.py +191 -0
  44. sentience/llm_provider.py +875 -0
  45. sentience/llm_provider_utils.py +120 -0
  46. sentience/llm_response_builder.py +153 -0
  47. sentience/models.py +846 -0
  48. sentience/ordinal.py +280 -0
  49. sentience/overlay.py +222 -0
  50. sentience/protocols.py +228 -0
  51. sentience/query.py +303 -0
  52. sentience/read.py +188 -0
  53. sentience/recorder.py +589 -0
  54. sentience/schemas/trace_v1.json +335 -0
  55. sentience/screenshot.py +100 -0
  56. sentience/sentience_methods.py +86 -0
  57. sentience/snapshot.py +706 -0
  58. sentience/snapshot_diff.py +126 -0
  59. sentience/text_search.py +262 -0
  60. sentience/trace_event_builder.py +148 -0
  61. sentience/trace_file_manager.py +197 -0
  62. sentience/trace_indexing/__init__.py +27 -0
  63. sentience/trace_indexing/index_schema.py +199 -0
  64. sentience/trace_indexing/indexer.py +414 -0
  65. sentience/tracer_factory.py +322 -0
  66. sentience/tracing.py +449 -0
  67. sentience/utils/__init__.py +40 -0
  68. sentience/utils/browser.py +46 -0
  69. sentience/utils/element.py +257 -0
  70. sentience/utils/formatting.py +59 -0
  71. sentience/utils.py +296 -0
  72. sentience/verification.py +380 -0
  73. sentience/visual_agent.py +2058 -0
  74. sentience/wait.py +139 -0
  75. sentienceapi-0.95.0.dist-info/METADATA +984 -0
  76. sentienceapi-0.95.0.dist-info/RECORD +82 -0
  77. sentienceapi-0.95.0.dist-info/WHEEL +5 -0
  78. sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
  79. sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
  80. sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
  81. sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
  82. sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
sentience/snapshot.py ADDED
@@ -0,0 +1,706 @@
1
+ """
2
+ Snapshot functionality - calls window.sentience.snapshot() or server-side API
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import time
9
+ from typing import Any, Optional
10
+
11
+ import requests
12
+
13
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
14
+ from .browser_evaluator import BrowserEvaluator
15
+ from .constants import SENTIENCE_API_URL
16
+ from .models import Snapshot, SnapshotOptions
17
+ from .sentience_methods import SentienceMethod
18
+
19
+ # Maximum payload size for API requests (10MB server limit)
20
+ MAX_PAYLOAD_BYTES = 10 * 1024 * 1024
21
+
22
+
23
+ def _build_snapshot_payload(
24
+ raw_result: dict[str, Any],
25
+ options: SnapshotOptions,
26
+ ) -> dict[str, Any]:
27
+ """
28
+ Build payload dict for gateway snapshot API.
29
+
30
+ Shared helper used by both sync and async snapshot implementations.
31
+ """
32
+ return {
33
+ "raw_elements": raw_result.get("raw_elements", []),
34
+ "url": raw_result.get("url", ""),
35
+ "viewport": raw_result.get("viewport"),
36
+ "goal": options.goal,
37
+ "options": {
38
+ "limit": options.limit,
39
+ "filter": options.filter.model_dump() if options.filter else None,
40
+ },
41
+ }
42
+
43
+
44
+ def _validate_payload_size(payload_json: str) -> None:
45
+ """
46
+ Validate payload size before sending to gateway.
47
+
48
+ Raises ValueError if payload exceeds server limit.
49
+ """
50
+ payload_size = len(payload_json.encode("utf-8"))
51
+ if payload_size > MAX_PAYLOAD_BYTES:
52
+ raise ValueError(
53
+ f"Payload size ({payload_size / 1024 / 1024:.2f}MB) exceeds server limit "
54
+ f"({MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB). "
55
+ f"Try reducing the number of elements on the page or filtering elements."
56
+ )
57
+
58
+
59
+ def _post_snapshot_to_gateway_sync(
60
+ payload: dict[str, Any],
61
+ api_key: str,
62
+ api_url: str = SENTIENCE_API_URL,
63
+ ) -> dict[str, Any]:
64
+ """
65
+ Post snapshot payload to gateway (synchronous).
66
+
67
+ Used by sync snapshot() function.
68
+ """
69
+ payload_json = json.dumps(payload)
70
+ _validate_payload_size(payload_json)
71
+
72
+ headers = {
73
+ "Authorization": f"Bearer {api_key}",
74
+ "Content-Type": "application/json",
75
+ }
76
+
77
+ response = requests.post(
78
+ f"{api_url}/v1/snapshot",
79
+ data=payload_json,
80
+ headers=headers,
81
+ timeout=30,
82
+ )
83
+ response.raise_for_status()
84
+ return response.json()
85
+
86
+
87
+ async def _post_snapshot_to_gateway_async(
88
+ payload: dict[str, Any],
89
+ api_key: str,
90
+ api_url: str = SENTIENCE_API_URL,
91
+ ) -> dict[str, Any]:
92
+ """
93
+ Post snapshot payload to gateway (asynchronous).
94
+
95
+ Used by async backend snapshot() function.
96
+ """
97
+ # Lazy import httpx - only needed for async API calls
98
+ import httpx
99
+
100
+ payload_json = json.dumps(payload)
101
+ _validate_payload_size(payload_json)
102
+
103
+ headers = {
104
+ "Authorization": f"Bearer {api_key}",
105
+ "Content-Type": "application/json",
106
+ }
107
+
108
+ async with httpx.AsyncClient(timeout=30.0) as client:
109
+ response = await client.post(
110
+ f"{api_url}/v1/snapshot",
111
+ content=payload_json,
112
+ headers=headers,
113
+ )
114
+ response.raise_for_status()
115
+ return response.json()
116
+
117
+
118
+ def _merge_api_result_with_local(
119
+ api_result: dict[str, Any],
120
+ raw_result: dict[str, Any],
121
+ ) -> dict[str, Any]:
122
+ """
123
+ Merge API result with local data (screenshot, etc.).
124
+
125
+ Shared helper used by both sync and async snapshot implementations.
126
+ """
127
+ return {
128
+ "status": api_result.get("status", "success"),
129
+ "timestamp": api_result.get("timestamp"),
130
+ "url": api_result.get("url", raw_result.get("url", "")),
131
+ "viewport": api_result.get("viewport", raw_result.get("viewport")),
132
+ "elements": api_result.get("elements", []),
133
+ "screenshot": raw_result.get("screenshot"), # Keep local screenshot
134
+ "screenshot_format": raw_result.get("screenshot_format"),
135
+ "error": api_result.get("error"),
136
+ # Phase 2: Ordinal support - dominant group key from Gateway
137
+ "dominant_group_key": api_result.get("dominant_group_key"),
138
+ }
139
+
140
+
141
+ def _save_trace_to_file(raw_elements: list[dict[str, Any]], trace_path: str | None = None) -> None:
142
+ """
143
+ Save raw_elements to a JSON file for benchmarking/training
144
+
145
+ Args:
146
+ raw_elements: Raw elements data from snapshot
147
+ trace_path: Path to save trace file. If None, uses "trace_{timestamp}.json"
148
+ """
149
+ # Default filename if none provided
150
+ filename = trace_path or f"trace_{int(time.time())}.json"
151
+
152
+ # Ensure directory exists
153
+ directory = os.path.dirname(filename)
154
+ if directory:
155
+ os.makedirs(directory, exist_ok=True)
156
+
157
+ # Save the raw elements to JSON
158
+ with open(filename, "w") as f:
159
+ json.dump(raw_elements, f, indent=2)
160
+
161
+ print(f"[SDK] Trace saved to: {filename}")
162
+
163
+
164
+ def snapshot(
165
+ browser: SentienceBrowser,
166
+ options: SnapshotOptions | None = None,
167
+ ) -> Snapshot:
168
+ """
169
+ Take a snapshot of the current page
170
+
171
+ Args:
172
+ browser: SentienceBrowser instance
173
+ options: Snapshot options (screenshot, limit, filter, etc.)
174
+ If None, uses default options.
175
+
176
+ Returns:
177
+ Snapshot object
178
+
179
+ Example:
180
+ # Basic snapshot with defaults
181
+ snap = snapshot(browser)
182
+
183
+ # With options
184
+ snap = snapshot(browser, SnapshotOptions(
185
+ screenshot=True,
186
+ limit=100,
187
+ show_overlay=True
188
+ ))
189
+ """
190
+ # Use default options if none provided
191
+ if options is None:
192
+ options = SnapshotOptions()
193
+
194
+ # Resolve API key: options.sentience_api_key takes precedence, then browser.api_key
195
+ # This allows browser-use users to pass api_key via options without SentienceBrowser
196
+ effective_api_key = options.sentience_api_key or browser.api_key
197
+
198
+ # Determine if we should use server-side API
199
+ should_use_api = (
200
+ options.use_api if options.use_api is not None else (effective_api_key is not None)
201
+ )
202
+
203
+ if should_use_api and effective_api_key:
204
+ # Use server-side API (Pro/Enterprise tier)
205
+ return _snapshot_via_api(browser, options, effective_api_key)
206
+ else:
207
+ # Use local extension (Free tier)
208
+ return _snapshot_via_extension(browser, options)
209
+
210
+
211
+ def _snapshot_via_extension(
212
+ browser: SentienceBrowser,
213
+ options: SnapshotOptions,
214
+ ) -> Snapshot:
215
+ """Take snapshot using local extension (Free tier)"""
216
+ if not browser.page:
217
+ raise RuntimeError("Browser not started. Call browser.start() first.")
218
+
219
+ # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
220
+ # The new architecture loads injected_api.js asynchronously, so window.sentience
221
+ # may not be immediately available after page load
222
+ BrowserEvaluator.wait_for_extension(browser.page, timeout_ms=5000)
223
+
224
+ # Build options dict for extension API (exclude save_trace/trace_path)
225
+ ext_options: dict[str, Any] = {}
226
+ if options.screenshot is not False:
227
+ # Serialize ScreenshotConfig to dict if it's a Pydantic model
228
+ if hasattr(options.screenshot, "model_dump"):
229
+ ext_options["screenshot"] = options.screenshot.model_dump()
230
+ else:
231
+ ext_options["screenshot"] = options.screenshot
232
+ if options.limit != 50:
233
+ ext_options["limit"] = options.limit
234
+ if options.filter is not None:
235
+ ext_options["filter"] = (
236
+ options.filter.model_dump() if hasattr(options.filter, "model_dump") else options.filter
237
+ )
238
+
239
+ # Call extension API
240
+ result = browser.page.evaluate(
241
+ """
242
+ (options) => {
243
+ return window.sentience.snapshot(options);
244
+ }
245
+ """,
246
+ ext_options,
247
+ )
248
+
249
+ # Save trace if requested
250
+ if options.save_trace:
251
+ _save_trace_to_file(result.get("raw_elements", []), options.trace_path)
252
+
253
+ # Validate and parse with Pydantic
254
+ snapshot_obj = Snapshot(**result)
255
+
256
+ # Show visual overlay if requested
257
+ if options.show_overlay:
258
+ raw_elements = result.get("raw_elements", [])
259
+ if raw_elements:
260
+ browser.page.evaluate(
261
+ """
262
+ (elements) => {
263
+ if (window.sentience && window.sentience.showOverlay) {
264
+ window.sentience.showOverlay(elements, null);
265
+ }
266
+ }
267
+ """,
268
+ raw_elements,
269
+ )
270
+
271
+ # Show grid overlay if requested
272
+ if options.show_grid:
273
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
274
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
275
+ if grids:
276
+ # Convert GridInfo to dict for JavaScript
277
+ grid_dicts = [grid.model_dump() for grid in grids]
278
+ # Pass grid_id as targetGridId to highlight it in red
279
+ target_grid_id = options.grid_id if options.grid_id is not None else None
280
+ browser.page.evaluate(
281
+ """
282
+ (grids, targetGridId) => {
283
+ if (window.sentience && window.sentience.showGrid) {
284
+ window.sentience.showGrid(grids, targetGridId);
285
+ } else {
286
+ console.warn('[SDK] showGrid not available in extension');
287
+ }
288
+ }
289
+ """,
290
+ grid_dicts,
291
+ target_grid_id,
292
+ )
293
+
294
+ return snapshot_obj
295
+
296
+
297
+ def _snapshot_via_api(
298
+ browser: SentienceBrowser,
299
+ options: SnapshotOptions,
300
+ api_key: str,
301
+ ) -> Snapshot:
302
+ """Take snapshot using server-side API (Pro/Enterprise tier)"""
303
+ if not browser.page:
304
+ raise RuntimeError("Browser not started. Call browser.start() first.")
305
+
306
+ # Use browser.api_url if set, otherwise default
307
+ api_url = browser.api_url or SENTIENCE_API_URL
308
+
309
+ # CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
310
+ # Even for API mode, we need the extension to collect raw data locally
311
+ BrowserEvaluator.wait_for_extension(browser.page, timeout_ms=5000)
312
+
313
+ # Step 1: Get raw data from local extension (always happens locally)
314
+ raw_options: dict[str, Any] = {}
315
+ if options.screenshot is not False:
316
+ raw_options["screenshot"] = options.screenshot
317
+
318
+ raw_result = BrowserEvaluator.invoke(browser.page, SentienceMethod.SNAPSHOT, **raw_options)
319
+
320
+ # Save trace if requested (save raw data before API processing)
321
+ if options.save_trace:
322
+ _save_trace_to_file(raw_result.get("raw_elements", []), options.trace_path)
323
+
324
+ # Step 2: Send to server for smart ranking/filtering
325
+ # Use raw_elements (raw data) instead of elements (processed data)
326
+ # Server validates API key and applies proprietary ranking logic
327
+ payload = _build_snapshot_payload(raw_result, options)
328
+
329
+ try:
330
+ api_result = _post_snapshot_to_gateway_sync(payload, api_key, api_url)
331
+
332
+ # Merge API result with local data (screenshot, etc.)
333
+ snapshot_data = _merge_api_result_with_local(api_result, raw_result)
334
+
335
+ # Create snapshot object
336
+ snapshot_obj = Snapshot(**snapshot_data)
337
+
338
+ # Show visual overlay if requested (use API-ranked elements)
339
+ if options.show_overlay:
340
+ elements = api_result.get("elements", [])
341
+ if elements:
342
+ browser.page.evaluate(
343
+ """
344
+ (elements) => {
345
+ if (window.sentience && window.sentience.showOverlay) {
346
+ window.sentience.showOverlay(elements, null);
347
+ }
348
+ }
349
+ """,
350
+ elements,
351
+ )
352
+
353
+ # Show grid overlay if requested
354
+ if options.show_grid:
355
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
356
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
357
+ if grids:
358
+ grid_dicts = [grid.model_dump() for grid in grids]
359
+ # Pass grid_id as targetGridId to highlight it in red
360
+ target_grid_id = options.grid_id if options.grid_id is not None else None
361
+ browser.page.evaluate(
362
+ """
363
+ (grids, targetGridId) => {
364
+ if (window.sentience && window.sentience.showGrid) {
365
+ window.sentience.showGrid(grids, targetGridId);
366
+ } else {
367
+ console.warn('[SDK] showGrid not available in extension');
368
+ }
369
+ }
370
+ """,
371
+ grid_dicts,
372
+ target_grid_id,
373
+ )
374
+
375
+ return snapshot_obj
376
+ except requests.exceptions.RequestException as e:
377
+ raise RuntimeError(f"API request failed: {e}") from e
378
+
379
+
380
+ # ========== Async Snapshot Functions ==========
381
+
382
+
383
+ async def snapshot_async(
384
+ browser: AsyncSentienceBrowser,
385
+ options: SnapshotOptions | None = None,
386
+ ) -> Snapshot:
387
+ """
388
+ Take a snapshot of the current page (async)
389
+
390
+ Args:
391
+ browser: AsyncSentienceBrowser instance
392
+ options: Snapshot options (screenshot, limit, filter, etc.)
393
+ If None, uses default options.
394
+
395
+ Returns:
396
+ Snapshot object
397
+
398
+ Example:
399
+ # Basic snapshot with defaults
400
+ snap = await snapshot_async(browser)
401
+
402
+ # With options
403
+ snap = await snapshot_async(browser, SnapshotOptions(
404
+ screenshot=True,
405
+ limit=100,
406
+ show_overlay=True
407
+ ))
408
+ """
409
+ # Use default options if none provided
410
+ if options is None:
411
+ options = SnapshotOptions()
412
+
413
+ # Resolve API key: options.sentience_api_key takes precedence, then browser.api_key
414
+ # This allows browser-use users to pass api_key via options without SentienceBrowser
415
+ effective_api_key = options.sentience_api_key or browser.api_key
416
+
417
+ # Determine if we should use server-side API
418
+ should_use_api = (
419
+ options.use_api if options.use_api is not None else (effective_api_key is not None)
420
+ )
421
+
422
+ if should_use_api and effective_api_key:
423
+ # Use server-side API (Pro/Enterprise tier)
424
+ return await _snapshot_via_api_async(browser, options, effective_api_key)
425
+ else:
426
+ # Use local extension (Free tier)
427
+ return await _snapshot_via_extension_async(browser, options)
428
+
429
+
430
+ async def _snapshot_via_extension_async(
431
+ browser: AsyncSentienceBrowser,
432
+ options: SnapshotOptions,
433
+ ) -> Snapshot:
434
+ """Take snapshot using local extension (Free tier) - async"""
435
+ if not browser.page:
436
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
437
+
438
+ # Wait for extension injection to complete
439
+ try:
440
+ await browser.page.wait_for_function(
441
+ "typeof window.sentience !== 'undefined'",
442
+ timeout=5000,
443
+ )
444
+ except Exception as e:
445
+ try:
446
+ diag = await browser.page.evaluate(
447
+ """() => ({
448
+ sentience_defined: typeof window.sentience !== 'undefined',
449
+ extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
450
+ url: window.location.href
451
+ })"""
452
+ )
453
+ except Exception:
454
+ diag = {"error": "Could not gather diagnostics"}
455
+
456
+ raise RuntimeError(
457
+ f"Sentience extension failed to inject window.sentience API. "
458
+ f"Is the extension loaded? Diagnostics: {diag}"
459
+ ) from e
460
+
461
+ # Build options dict for extension API
462
+ ext_options: dict[str, Any] = {}
463
+ if options.screenshot is not False:
464
+ # Serialize ScreenshotConfig to dict if it's a Pydantic model
465
+ if hasattr(options.screenshot, "model_dump"):
466
+ ext_options["screenshot"] = options.screenshot.model_dump()
467
+ else:
468
+ ext_options["screenshot"] = options.screenshot
469
+ if options.limit != 50:
470
+ ext_options["limit"] = options.limit
471
+ if options.filter is not None:
472
+ ext_options["filter"] = (
473
+ options.filter.model_dump() if hasattr(options.filter, "model_dump") else options.filter
474
+ )
475
+
476
+ # Call extension API
477
+ result = await browser.page.evaluate(
478
+ """
479
+ (options) => {
480
+ return window.sentience.snapshot(options);
481
+ }
482
+ """,
483
+ ext_options,
484
+ )
485
+ if result.get("error"):
486
+ print(f" Snapshot error: {result.get('error')}")
487
+
488
+ # Save trace if requested
489
+ if options.save_trace:
490
+ _save_trace_to_file(result.get("raw_elements", []), options.trace_path)
491
+
492
+ # Extract screenshot_format from data URL if not provided by extension
493
+ if result.get("screenshot") and not result.get("screenshot_format"):
494
+ screenshot_data_url = result.get("screenshot", "")
495
+ if screenshot_data_url.startswith("data:image/"):
496
+ # Extract format from "data:image/jpeg;base64,..." or "data:image/png;base64,..."
497
+ format_match = screenshot_data_url.split(";")[0].split("/")[-1]
498
+ if format_match in ["jpeg", "jpg", "png"]:
499
+ result["screenshot_format"] = "jpeg" if format_match in ["jpeg", "jpg"] else "png"
500
+
501
+ # Validate and parse with Pydantic
502
+ snapshot_obj = Snapshot(**result)
503
+
504
+ # Show visual overlay if requested
505
+ if options.show_overlay:
506
+ raw_elements = result.get("raw_elements", [])
507
+ if raw_elements:
508
+ await browser.page.evaluate(
509
+ """
510
+ (elements) => {
511
+ if (window.sentience && window.sentience.showOverlay) {
512
+ window.sentience.showOverlay(elements, null);
513
+ }
514
+ }
515
+ """,
516
+ raw_elements,
517
+ )
518
+
519
+ # Show grid overlay if requested
520
+ if options.show_grid:
521
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
522
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
523
+ if grids:
524
+ grid_dicts = [grid.model_dump() for grid in grids]
525
+ # Pass grid_id as targetGridId to highlight it in red
526
+ target_grid_id = options.grid_id if options.grid_id is not None else None
527
+ await browser.page.evaluate(
528
+ """
529
+ (grids, targetGridId) => {
530
+ if (window.sentience && window.sentience.showGrid) {
531
+ window.sentience.showGrid(grids, targetGridId);
532
+ } else {
533
+ console.warn('[SDK] showGrid not available in extension');
534
+ }
535
+ }
536
+ """,
537
+ grid_dicts,
538
+ target_grid_id,
539
+ )
540
+
541
+ return snapshot_obj
542
+
543
+
544
+ async def _snapshot_via_api_async(
545
+ browser: AsyncSentienceBrowser,
546
+ options: SnapshotOptions,
547
+ api_key: str,
548
+ ) -> Snapshot:
549
+ """Take snapshot using server-side API (Pro/Enterprise tier) - async"""
550
+ if not browser.page:
551
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
552
+
553
+ # Use browser.api_url if set, otherwise default
554
+ api_url = browser.api_url or SENTIENCE_API_URL
555
+
556
+ # Wait for extension injection
557
+ try:
558
+ await browser.page.wait_for_function(
559
+ "typeof window.sentience !== 'undefined'", timeout=5000
560
+ )
561
+ except Exception as e:
562
+ raise RuntimeError(
563
+ "Sentience extension failed to inject. Cannot collect raw data for API processing."
564
+ ) from e
565
+
566
+ # Step 1: Get raw data from local extension (including screenshot)
567
+ raw_options: dict[str, Any] = {}
568
+ screenshot_requested = False
569
+ if options.screenshot is not False:
570
+ screenshot_requested = True
571
+ # Serialize ScreenshotConfig to dict if it's a Pydantic model
572
+ if hasattr(options.screenshot, "model_dump"):
573
+ raw_options["screenshot"] = options.screenshot.model_dump()
574
+ else:
575
+ raw_options["screenshot"] = options.screenshot
576
+
577
+ raw_result = await browser.page.evaluate(
578
+ """
579
+ (options) => {
580
+ return window.sentience.snapshot(options);
581
+ }
582
+ """,
583
+ raw_options,
584
+ )
585
+
586
+ # Extract screenshot from raw result (extension captures it, but API doesn't return it)
587
+ screenshot_data_url = raw_result.get("screenshot")
588
+ screenshot_format = None
589
+ if screenshot_data_url:
590
+ # Extract format from data URL
591
+ if screenshot_data_url.startswith("data:image/"):
592
+ format_match = screenshot_data_url.split(";")[0].split("/")[-1]
593
+ if format_match in ["jpeg", "jpg", "png"]:
594
+ screenshot_format = "jpeg" if format_match in ["jpeg", "jpg"] else "png"
595
+
596
+ # Save trace if requested
597
+ if options.save_trace:
598
+ _save_trace_to_file(raw_result.get("raw_elements", []), options.trace_path)
599
+
600
+ # Step 2: Send to server for smart ranking/filtering
601
+ payload = {
602
+ "raw_elements": raw_result.get("raw_elements", []),
603
+ "url": raw_result.get("url", ""),
604
+ "viewport": raw_result.get("viewport"),
605
+ "goal": options.goal,
606
+ "options": {
607
+ "limit": options.limit,
608
+ "filter": options.filter.model_dump() if options.filter else None,
609
+ },
610
+ }
611
+
612
+ # Check payload size
613
+ payload_json = json.dumps(payload)
614
+ payload_size = len(payload_json.encode("utf-8"))
615
+ if payload_size > MAX_PAYLOAD_BYTES:
616
+ raise ValueError(
617
+ f"Payload size ({payload_size / 1024 / 1024:.2f}MB) exceeds server limit "
618
+ f"({MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB). "
619
+ f"Try reducing the number of elements on the page or filtering elements."
620
+ )
621
+
622
+ headers = {
623
+ "Authorization": f"Bearer {api_key}",
624
+ "Content-Type": "application/json",
625
+ }
626
+
627
+ try:
628
+ # Lazy import httpx - only needed for async API calls
629
+ import httpx
630
+
631
+ async with httpx.AsyncClient(timeout=30.0) as client:
632
+ response = await client.post(
633
+ f"{api_url}/v1/snapshot",
634
+ content=payload_json,
635
+ headers=headers,
636
+ )
637
+ response.raise_for_status()
638
+ api_result = response.json()
639
+
640
+ # Extract screenshot format from data URL if not provided
641
+ if screenshot_data_url and not screenshot_format:
642
+ if screenshot_data_url.startswith("data:image/"):
643
+ format_match = screenshot_data_url.split(";")[0].split("/")[-1]
644
+ if format_match in ["jpeg", "jpg", "png"]:
645
+ screenshot_format = "jpeg" if format_match in ["jpeg", "jpg"] else "png"
646
+
647
+ # Merge API result with local data
648
+ snapshot_data = {
649
+ "status": api_result.get("status", "success"),
650
+ "timestamp": api_result.get("timestamp"),
651
+ "url": api_result.get("url", raw_result.get("url", "")),
652
+ "viewport": api_result.get("viewport", raw_result.get("viewport")),
653
+ "elements": api_result.get("elements", []),
654
+ "screenshot": screenshot_data_url, # Use the extracted screenshot
655
+ "screenshot_format": screenshot_format, # Use the extracted format
656
+ "error": api_result.get("error"),
657
+ }
658
+
659
+ # Create snapshot object
660
+ snapshot_obj = Snapshot(**snapshot_data)
661
+
662
+ # Show visual overlay if requested
663
+ if options.show_overlay:
664
+ elements = api_result.get("elements", [])
665
+ if elements:
666
+ await browser.page.evaluate(
667
+ """
668
+ (elements) => {
669
+ if (window.sentience && window.sentience.showOverlay) {
670
+ window.sentience.showOverlay(elements, null);
671
+ }
672
+ }
673
+ """,
674
+ elements,
675
+ )
676
+
677
+ # Show grid overlay if requested
678
+ if options.show_grid:
679
+ # Get all grids (don't filter by grid_id here - we want to show all but highlight the target)
680
+ grids = snapshot_obj.get_grid_bounds(grid_id=None)
681
+ if grids:
682
+ grid_dicts = [grid.model_dump() for grid in grids]
683
+ # Pass grid_id as targetGridId to highlight it in red
684
+ target_grid_id = options.grid_id if options.grid_id is not None else None
685
+ await browser.page.evaluate(
686
+ """
687
+ (grids, targetGridId) => {
688
+ if (window.sentience && window.sentience.showGrid) {
689
+ window.sentience.showGrid(grids, targetGridId);
690
+ } else {
691
+ console.warn('[SDK] showGrid not available in extension');
692
+ }
693
+ }
694
+ """,
695
+ grid_dicts,
696
+ target_grid_id,
697
+ )
698
+
699
+ return snapshot_obj
700
+ except ImportError:
701
+ # Fallback to requests if httpx not available (shouldn't happen in async context)
702
+ raise RuntimeError(
703
+ "httpx is required for async API calls. Install it with: pip install httpx"
704
+ )
705
+ except Exception as e:
706
+ raise RuntimeError(f"API request failed: {e}")