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.
- sentience/__init__.py +253 -0
- sentience/_extension_loader.py +195 -0
- sentience/action_executor.py +215 -0
- sentience/actions.py +1020 -0
- sentience/agent.py +1181 -0
- sentience/agent_config.py +46 -0
- sentience/agent_runtime.py +424 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +108 -0
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +343 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +427 -0
- sentience/base_agent.py +196 -0
- sentience/browser.py +1215 -0
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cli.py +130 -0
- sentience/cloud_tracing.py +807 -0
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +543 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +188 -0
- sentience/extension/background.js +104 -0
- sentience/extension/content.js +161 -0
- sentience/extension/injected_api.js +914 -0
- sentience/extension/manifest.json +36 -0
- sentience/extension/pkg/sentience_core.d.ts +51 -0
- sentience/extension/pkg/sentience_core.js +323 -0
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
- sentience/extension/release.json +115 -0
- sentience/formatting.py +15 -0
- sentience/generator.py +202 -0
- sentience/inspector.py +367 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +875 -0
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +846 -0
- sentience/ordinal.py +280 -0
- sentience/overlay.py +222 -0
- sentience/protocols.py +228 -0
- sentience/query.py +303 -0
- sentience/read.py +188 -0
- sentience/recorder.py +589 -0
- sentience/schemas/trace_v1.json +335 -0
- sentience/screenshot.py +100 -0
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +706 -0
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +262 -0
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/__init__.py +27 -0
- sentience/trace_indexing/index_schema.py +199 -0
- sentience/trace_indexing/indexer.py +414 -0
- sentience/tracer_factory.py +322 -0
- sentience/tracing.py +449 -0
- 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 +296 -0
- sentience/verification.py +380 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +139 -0
- sentienceapi-0.95.0.dist-info/METADATA +984 -0
- sentienceapi-0.95.0.dist-info/RECORD +82 -0
- sentienceapi-0.95.0.dist-info/WHEEL +5 -0
- sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
- sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
- sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Element manipulation and digest utilities for Sentience SDK.
|
|
3
|
+
|
|
4
|
+
Provides functions to compute stable digests of snapshots for deterministic diff.
|
|
5
|
+
Two digest strategies:
|
|
6
|
+
- strict: includes structure + normalized text
|
|
7
|
+
- loose: structure only (no text) - detects layout changes vs content changes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BBox:
|
|
19
|
+
"""Bounding box with normalized coordinates."""
|
|
20
|
+
|
|
21
|
+
x: int
|
|
22
|
+
y: int
|
|
23
|
+
width: int
|
|
24
|
+
height: int
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_dict(cls, bbox_dict: dict[str, Any]) -> "BBox":
|
|
28
|
+
"""Create BBox from dictionary."""
|
|
29
|
+
return cls(
|
|
30
|
+
x=int(bbox_dict.get("x", 0)),
|
|
31
|
+
y=int(bbox_dict.get("y", 0)),
|
|
32
|
+
width=int(bbox_dict.get("width", 0)),
|
|
33
|
+
height=int(bbox_dict.get("height", 0)),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def to_normalized(self, bucket_size: int = 2) -> list[int]:
|
|
37
|
+
"""
|
|
38
|
+
Normalize bbox to fixed-size buckets to ignore minor jitter.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
bucket_size: Pixel bucket size (default 2px)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of [x, y, width, height] rounded to buckets
|
|
45
|
+
"""
|
|
46
|
+
return [
|
|
47
|
+
round(self.x / bucket_size) * bucket_size,
|
|
48
|
+
round(self.y / bucket_size) * bucket_size,
|
|
49
|
+
round(self.width / bucket_size) * bucket_size,
|
|
50
|
+
round(self.height / bucket_size) * bucket_size,
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ElementFingerprint:
|
|
56
|
+
"""Normalized element data for digest computation."""
|
|
57
|
+
|
|
58
|
+
id: int
|
|
59
|
+
role: str
|
|
60
|
+
bbox: list[int] # Normalized
|
|
61
|
+
clickable: int # 0 or 1
|
|
62
|
+
primary: int # 0 or 1
|
|
63
|
+
text: str = "" # Empty for loose digest
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
"""Convert to dictionary for JSON serialization."""
|
|
67
|
+
data = {
|
|
68
|
+
"id": self.id,
|
|
69
|
+
"role": self.role,
|
|
70
|
+
"bbox": self.bbox,
|
|
71
|
+
"clickable": self.clickable,
|
|
72
|
+
"primary": self.primary,
|
|
73
|
+
}
|
|
74
|
+
if self.text: # Only include text if non-empty
|
|
75
|
+
data["text"] = self.text
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize_text_strict(text: str | None, max_length: int = 80) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Normalize text for strict digest (structure + content).
|
|
82
|
+
|
|
83
|
+
Rules:
|
|
84
|
+
- Lowercase
|
|
85
|
+
- Trim and collapse whitespace
|
|
86
|
+
- Cap length at max_length
|
|
87
|
+
- Replace digit runs with '#'
|
|
88
|
+
- Normalize currency: $79.99 -> $#
|
|
89
|
+
- Normalize time patterns: 12:34 -> #:#
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
text: Input text
|
|
93
|
+
max_length: Maximum text length (default 80)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Normalized text string
|
|
97
|
+
"""
|
|
98
|
+
if not text:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
# Lowercase and trim
|
|
102
|
+
text = text.strip().lower()
|
|
103
|
+
|
|
104
|
+
# Collapse whitespace
|
|
105
|
+
text = " ".join(text.split())
|
|
106
|
+
|
|
107
|
+
# Cap length
|
|
108
|
+
text = text[:max_length]
|
|
109
|
+
|
|
110
|
+
# Replace digit runs with #
|
|
111
|
+
text = re.sub(r"\d+", "#", text)
|
|
112
|
+
|
|
113
|
+
# Normalize currency
|
|
114
|
+
text = re.sub(r"\$\s*#", "$#", text)
|
|
115
|
+
|
|
116
|
+
# Normalize time patterns (HH:MM or similar)
|
|
117
|
+
text = re.sub(r"#:#", "#:#", text)
|
|
118
|
+
|
|
119
|
+
# Normalize date patterns (YYYY-MM-DD or similar)
|
|
120
|
+
text = re.sub(r"#-#-#", "#-#-#", text)
|
|
121
|
+
|
|
122
|
+
return text
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def normalize_bbox(bbox: dict[str, Any] | BBox, bucket_size: int = 2) -> list[int]:
|
|
126
|
+
"""
|
|
127
|
+
Round bbox to fixed-size buckets to ignore jitter.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
bbox: BBox object or dict with x, y, width, height
|
|
131
|
+
bucket_size: Pixel bucket size (default 2px)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of [x, y, width, height] rounded to buckets
|
|
135
|
+
"""
|
|
136
|
+
if isinstance(bbox, BBox):
|
|
137
|
+
return bbox.to_normalized(bucket_size)
|
|
138
|
+
|
|
139
|
+
bbox_obj = BBox.from_dict(bbox)
|
|
140
|
+
return bbox_obj.to_normalized(bucket_size)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def extract_element_fingerprint(
|
|
144
|
+
element: dict[str, Any],
|
|
145
|
+
include_text: bool = True,
|
|
146
|
+
) -> ElementFingerprint:
|
|
147
|
+
"""
|
|
148
|
+
Extract normalized fingerprint from element dict.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
element: Element dict from snapshot
|
|
152
|
+
include_text: Whether to include normalized text (False for loose digest)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
ElementFingerprint with normalized data
|
|
156
|
+
"""
|
|
157
|
+
# Extract basic fields
|
|
158
|
+
element_id = element.get("id", 0)
|
|
159
|
+
role = element.get("role", "unknown")
|
|
160
|
+
|
|
161
|
+
# Extract and normalize bbox
|
|
162
|
+
bbox_data = element.get("bbox", {})
|
|
163
|
+
bbox_normalized = normalize_bbox(bbox_data)
|
|
164
|
+
|
|
165
|
+
# Extract visual cues
|
|
166
|
+
visual_cues = element.get("visual_cues", {})
|
|
167
|
+
clickable = 1 if visual_cues.get("is_clickable", False) else 0
|
|
168
|
+
primary = 1 if visual_cues.get("is_primary", False) else 0
|
|
169
|
+
|
|
170
|
+
# Extract and normalize text (if requested)
|
|
171
|
+
text = ""
|
|
172
|
+
if include_text:
|
|
173
|
+
raw_text = element.get("text", "")
|
|
174
|
+
text = normalize_text_strict(raw_text)
|
|
175
|
+
|
|
176
|
+
return ElementFingerprint(
|
|
177
|
+
id=element_id,
|
|
178
|
+
role=role,
|
|
179
|
+
bbox=bbox_normalized,
|
|
180
|
+
clickable=clickable,
|
|
181
|
+
primary=primary,
|
|
182
|
+
text=text,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def canonical_snapshot_strict(elements: list[dict[str, Any]]) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Create strict snapshot digest (structure + normalized text).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
elements: List of element dicts from snapshot
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Canonical JSON string for hashing
|
|
195
|
+
"""
|
|
196
|
+
fingerprints = []
|
|
197
|
+
|
|
198
|
+
for element in sorted(elements, key=lambda e: e.get("id", 0)):
|
|
199
|
+
fingerprint = extract_element_fingerprint(element, include_text=True)
|
|
200
|
+
fingerprints.append(fingerprint.to_dict())
|
|
201
|
+
|
|
202
|
+
return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def canonical_snapshot_loose(elements: list[dict[str, Any]]) -> str:
|
|
206
|
+
"""
|
|
207
|
+
Create loose snapshot digest (structure only, no text).
|
|
208
|
+
|
|
209
|
+
This is more resistant to content churn (prices, ads, timestamps).
|
|
210
|
+
Use for detecting structural changes vs content changes.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
elements: List of element dicts from snapshot
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Canonical JSON string for hashing
|
|
217
|
+
"""
|
|
218
|
+
fingerprints = []
|
|
219
|
+
|
|
220
|
+
for element in sorted(elements, key=lambda e: e.get("id", 0)):
|
|
221
|
+
fingerprint = extract_element_fingerprint(element, include_text=False)
|
|
222
|
+
fingerprints.append(fingerprint.to_dict())
|
|
223
|
+
|
|
224
|
+
return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def sha256_digest(canonical_str: str) -> str:
|
|
228
|
+
"""
|
|
229
|
+
Compute SHA256 hash with 'sha256:' prefix.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
canonical_str: Canonical string to hash
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Hash string with format: "sha256:<hex>"
|
|
236
|
+
"""
|
|
237
|
+
hash_obj = hashlib.sha256(canonical_str.encode("utf-8"))
|
|
238
|
+
return f"sha256:{hash_obj.hexdigest()}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def compute_snapshot_digests(elements: list[dict[str, Any]]) -> dict[str, str]:
|
|
242
|
+
"""
|
|
243
|
+
Compute both strict and loose digests for a snapshot.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
elements: List of element dicts from snapshot
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Dict with 'strict' and 'loose' digest strings
|
|
250
|
+
"""
|
|
251
|
+
canonical_strict = canonical_snapshot_strict(elements)
|
|
252
|
+
canonical_loose = canonical_snapshot_loose(elements)
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"strict": sha256_digest(canonical_strict),
|
|
256
|
+
"loose": sha256_digest(canonical_loose),
|
|
257
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snapshot formatting utilities for LLM prompts.
|
|
3
|
+
|
|
4
|
+
Provides functions to convert Sentience snapshots into text format suitable
|
|
5
|
+
for LLM consumption.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from ..models import Snapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_snapshot_for_llm(snap: Snapshot, limit: int = 50) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Convert snapshot elements to text format for LLM consumption.
|
|
16
|
+
|
|
17
|
+
This is the canonical way Sentience formats DOM state for LLMs.
|
|
18
|
+
The format includes element ID, role, text preview, visual cues,
|
|
19
|
+
position, and importance score.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
snap: Snapshot object with elements
|
|
23
|
+
limit: Maximum number of elements to include (default: 50)
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Formatted string with one element per line
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> snap = snapshot(browser)
|
|
30
|
+
>>> formatted = format_snapshot_for_llm(snap, limit=10)
|
|
31
|
+
>>> print(formatted)
|
|
32
|
+
[1] <button> "Sign In" {PRIMARY,CLICKABLE} @ (100,50) (Imp:10)
|
|
33
|
+
[2] <input> "Email address" @ (100,100) (Imp:8)
|
|
34
|
+
...
|
|
35
|
+
"""
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
|
|
38
|
+
for el in snap.elements[:limit]:
|
|
39
|
+
# Build visual cues string
|
|
40
|
+
cues = []
|
|
41
|
+
if getattr(el.visual_cues, "is_primary", False):
|
|
42
|
+
cues.append("PRIMARY")
|
|
43
|
+
if getattr(el.visual_cues, "is_clickable", False):
|
|
44
|
+
cues.append("CLICKABLE")
|
|
45
|
+
|
|
46
|
+
cues_str = f" {{{','.join(cues)}}}" if cues else ""
|
|
47
|
+
|
|
48
|
+
# Format text preview (truncate to 50 chars)
|
|
49
|
+
text_preview = el.text or ""
|
|
50
|
+
if len(text_preview) > 50:
|
|
51
|
+
text_preview = text_preview[:50] + "..."
|
|
52
|
+
|
|
53
|
+
# Build element line: [ID] <role> "text" {cues} @ (x,y) (Imp:score)
|
|
54
|
+
lines.append(
|
|
55
|
+
f'[{el.id}] <{el.role}> "{text_preview}"{cues_str} '
|
|
56
|
+
f"@ ({int(el.bbox.x)},{int(el.bbox.y)}) (Imp:{el.importance})"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
sentience/utils.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Digest utilities for snapshot canonicalization and hashing.
|
|
3
|
+
|
|
4
|
+
Provides functions to compute stable digests of snapshots for determinism diff.
|
|
5
|
+
Two digest strategies:
|
|
6
|
+
- strict: includes structure + normalized text
|
|
7
|
+
- loose: structure only (no text) - detects layout changes vs content changes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from playwright.sync_api import BrowserContext
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BBox:
|
|
22
|
+
"""Bounding box with normalized coordinates."""
|
|
23
|
+
|
|
24
|
+
x: int
|
|
25
|
+
y: int
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, bbox_dict: dict[str, Any]) -> "BBox":
|
|
31
|
+
"""Create BBox from dictionary."""
|
|
32
|
+
return cls(
|
|
33
|
+
x=int(bbox_dict.get("x", 0)),
|
|
34
|
+
y=int(bbox_dict.get("y", 0)),
|
|
35
|
+
width=int(bbox_dict.get("width", 0)),
|
|
36
|
+
height=int(bbox_dict.get("height", 0)),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def to_normalized(self, bucket_size: int = 2) -> list[int]:
|
|
40
|
+
"""
|
|
41
|
+
Normalize bbox to fixed-size buckets to ignore minor jitter.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
bucket_size: Pixel bucket size (default 2px)
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of [x, y, width, height] rounded to buckets
|
|
48
|
+
"""
|
|
49
|
+
return [
|
|
50
|
+
round(self.x / bucket_size) * bucket_size,
|
|
51
|
+
round(self.y / bucket_size) * bucket_size,
|
|
52
|
+
round(self.width / bucket_size) * bucket_size,
|
|
53
|
+
round(self.height / bucket_size) * bucket_size,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ElementFingerprint:
|
|
59
|
+
"""Normalized element data for digest computation."""
|
|
60
|
+
|
|
61
|
+
id: int
|
|
62
|
+
role: str
|
|
63
|
+
bbox: list[int] # Normalized
|
|
64
|
+
clickable: int # 0 or 1
|
|
65
|
+
primary: int # 0 or 1
|
|
66
|
+
text: str = "" # Empty for loose digest
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
"""Convert to dictionary for JSON serialization."""
|
|
70
|
+
data = {
|
|
71
|
+
"id": self.id,
|
|
72
|
+
"role": self.role,
|
|
73
|
+
"bbox": self.bbox,
|
|
74
|
+
"clickable": self.clickable,
|
|
75
|
+
"primary": self.primary,
|
|
76
|
+
}
|
|
77
|
+
if self.text: # Only include text if non-empty
|
|
78
|
+
data["text"] = self.text
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_text_strict(text: str | None, max_length: int = 80) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Normalize text for strict digest (structure + content).
|
|
85
|
+
|
|
86
|
+
Rules:
|
|
87
|
+
- Lowercase
|
|
88
|
+
- Trim and collapse whitespace
|
|
89
|
+
- Cap length at max_length
|
|
90
|
+
- Replace digit runs with '#'
|
|
91
|
+
- Normalize currency: $79.99 -> $#
|
|
92
|
+
- Normalize time patterns: 12:34 -> #:#
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
text: Input text
|
|
96
|
+
max_length: Maximum text length (default 80)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Normalized text string
|
|
100
|
+
"""
|
|
101
|
+
if not text:
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
# Lowercase and trim
|
|
105
|
+
text = text.strip().lower()
|
|
106
|
+
|
|
107
|
+
# Collapse whitespace
|
|
108
|
+
text = " ".join(text.split())
|
|
109
|
+
|
|
110
|
+
# Cap length
|
|
111
|
+
text = text[:max_length]
|
|
112
|
+
|
|
113
|
+
# Replace digit runs with #
|
|
114
|
+
text = re.sub(r"\d+", "#", text)
|
|
115
|
+
|
|
116
|
+
# Normalize currency
|
|
117
|
+
text = re.sub(r"\$\s*#", "$#", text)
|
|
118
|
+
|
|
119
|
+
# Normalize time patterns (HH:MM or similar)
|
|
120
|
+
text = re.sub(r"#:#", "#:#", text)
|
|
121
|
+
|
|
122
|
+
# Normalize date patterns (YYYY-MM-DD or similar)
|
|
123
|
+
text = re.sub(r"#-#-#", "#-#-#", text)
|
|
124
|
+
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalize_bbox(bbox: dict[str, Any] | BBox, bucket_size: int = 2) -> list[int]:
|
|
129
|
+
"""
|
|
130
|
+
Round bbox to fixed-size buckets to ignore jitter.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
bbox: BBox object or dict with x, y, width, height
|
|
134
|
+
bucket_size: Pixel bucket size (default 2px)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of [x, y, width, height] rounded to buckets
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(bbox, BBox):
|
|
140
|
+
return bbox.to_normalized(bucket_size)
|
|
141
|
+
|
|
142
|
+
bbox_obj = BBox.from_dict(bbox)
|
|
143
|
+
return bbox_obj.to_normalized(bucket_size)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def extract_element_fingerprint(
|
|
147
|
+
element: dict[str, Any],
|
|
148
|
+
include_text: bool = True,
|
|
149
|
+
) -> ElementFingerprint:
|
|
150
|
+
"""
|
|
151
|
+
Extract normalized fingerprint from element dict.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
element: Element dict from snapshot
|
|
155
|
+
include_text: Whether to include normalized text (False for loose digest)
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
ElementFingerprint with normalized data
|
|
159
|
+
"""
|
|
160
|
+
# Extract basic fields
|
|
161
|
+
element_id = element.get("id", 0)
|
|
162
|
+
role = element.get("role", "unknown")
|
|
163
|
+
|
|
164
|
+
# Extract and normalize bbox
|
|
165
|
+
bbox_data = element.get("bbox", {})
|
|
166
|
+
bbox_normalized = normalize_bbox(bbox_data)
|
|
167
|
+
|
|
168
|
+
# Extract visual cues
|
|
169
|
+
visual_cues = element.get("visual_cues", {})
|
|
170
|
+
clickable = 1 if visual_cues.get("is_clickable", False) else 0
|
|
171
|
+
primary = 1 if visual_cues.get("is_primary", False) else 0
|
|
172
|
+
|
|
173
|
+
# Extract and normalize text (if requested)
|
|
174
|
+
text = ""
|
|
175
|
+
if include_text:
|
|
176
|
+
raw_text = element.get("text", "")
|
|
177
|
+
text = normalize_text_strict(raw_text)
|
|
178
|
+
|
|
179
|
+
return ElementFingerprint(
|
|
180
|
+
id=element_id,
|
|
181
|
+
role=role,
|
|
182
|
+
bbox=bbox_normalized,
|
|
183
|
+
clickable=clickable,
|
|
184
|
+
primary=primary,
|
|
185
|
+
text=text,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def canonical_snapshot_strict(elements: list[dict[str, Any]]) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Create strict snapshot digest (structure + normalized text).
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
elements: List of element dicts from snapshot
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Canonical JSON string for hashing
|
|
198
|
+
"""
|
|
199
|
+
fingerprints = []
|
|
200
|
+
|
|
201
|
+
for element in sorted(elements, key=lambda e: e.get("id", 0)):
|
|
202
|
+
fingerprint = extract_element_fingerprint(element, include_text=True)
|
|
203
|
+
fingerprints.append(fingerprint.to_dict())
|
|
204
|
+
|
|
205
|
+
return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def canonical_snapshot_loose(elements: list[dict[str, Any]]) -> str:
|
|
209
|
+
"""
|
|
210
|
+
Create loose snapshot digest (structure only, no text).
|
|
211
|
+
|
|
212
|
+
This is more resistant to content churn (prices, ads, timestamps).
|
|
213
|
+
Use for detecting structural changes vs content changes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
elements: List of element dicts from snapshot
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Canonical JSON string for hashing
|
|
220
|
+
"""
|
|
221
|
+
fingerprints = []
|
|
222
|
+
|
|
223
|
+
for element in sorted(elements, key=lambda e: e.get("id", 0)):
|
|
224
|
+
fingerprint = extract_element_fingerprint(element, include_text=False)
|
|
225
|
+
fingerprints.append(fingerprint.to_dict())
|
|
226
|
+
|
|
227
|
+
return json.dumps(fingerprints, sort_keys=True, ensure_ascii=False)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def sha256_digest(canonical_str: str) -> str:
|
|
231
|
+
"""
|
|
232
|
+
Compute SHA256 hash with 'sha256:' prefix.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
canonical_str: Canonical string to hash
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Hash string with format: "sha256:<hex>"
|
|
239
|
+
"""
|
|
240
|
+
hash_obj = hashlib.sha256(canonical_str.encode("utf-8"))
|
|
241
|
+
return f"sha256:{hash_obj.hexdigest()}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def compute_snapshot_digests(elements: list[dict[str, Any]]) -> dict[str, str]:
|
|
245
|
+
"""
|
|
246
|
+
Compute both strict and loose digests for a snapshot.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
elements: List of element dicts from snapshot
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Dict with 'strict' and 'loose' digest strings
|
|
253
|
+
"""
|
|
254
|
+
canonical_strict = canonical_snapshot_strict(elements)
|
|
255
|
+
canonical_loose = canonical_snapshot_loose(elements)
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"strict": sha256_digest(canonical_strict),
|
|
259
|
+
"loose": sha256_digest(canonical_loose),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def save_storage_state(context: BrowserContext, file_path: str | Path) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Save current browser storage state (cookies + localStorage) to a file.
|
|
266
|
+
|
|
267
|
+
This is useful for capturing a logged-in session to reuse later.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
context: Playwright BrowserContext
|
|
271
|
+
file_path: Path to save the storage state JSON file
|
|
272
|
+
|
|
273
|
+
Example:
|
|
274
|
+
```python
|
|
275
|
+
from sentience import SentienceBrowser, save_storage_state
|
|
276
|
+
|
|
277
|
+
browser = SentienceBrowser()
|
|
278
|
+
browser.start()
|
|
279
|
+
|
|
280
|
+
# User logs in manually or via agent
|
|
281
|
+
browser.goto("https://example.com")
|
|
282
|
+
# ... login happens ...
|
|
283
|
+
|
|
284
|
+
# Save session for later
|
|
285
|
+
save_storage_state(browser.context, "auth.json")
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
IOError: If file cannot be written
|
|
290
|
+
"""
|
|
291
|
+
storage_state = context.storage_state()
|
|
292
|
+
file_path_obj = Path(file_path)
|
|
293
|
+
file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
with open(file_path_obj, "w") as f:
|
|
295
|
+
json.dump(storage_state, f, indent=2)
|
|
296
|
+
print(f"✅ [Sentience] Saved storage state to {file_path_obj}")
|