yunti-browser-runtime 0.1.3 → 0.2.0

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.
@@ -0,0 +1,588 @@
1
+ ;(function installYuntiDomObserver(globalScope) {
2
+ const DEFAULT_MAX_ELEMENTS = 200
3
+ const DEFAULT_MAX_TEXT_LENGTH = 12000
4
+ const MAX_ELEMENTS = 500
5
+ const MAX_TEXT_LENGTH = 60000
6
+ const INTERACTIVE_SELECTOR = [
7
+ "a[href]",
8
+ "button",
9
+ "input",
10
+ "select",
11
+ "textarea",
12
+ "[contenteditable='true']",
13
+ "[contenteditable='']",
14
+ "[role='button']",
15
+ "[role='link']",
16
+ "[role='textbox']",
17
+ "[role='combobox']",
18
+ "[role='checkbox']",
19
+ "[role='radio']",
20
+ "[role='switch']",
21
+ "[role='menuitem']",
22
+ "[role='tab']",
23
+ "[onclick]",
24
+ ].join(",")
25
+
26
+ const SENSITIVE_KEY_RE =
27
+ /password|passwd|pwd|token|secret|auth|cookie|session|api[-_]?key|credential|jwt|bearer|private[-_]?key|otp|验证码|密码|令牌/i
28
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
29
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/i
30
+ const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/
31
+ const LONG_RANDOM_RE = /\b[A-Za-z0-9_-]{32,}\b/
32
+ const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i
33
+ const PHONE_RE = /(?:\+?\d[\d\s().-]{7,}\d)/
34
+ const PAYMENT_CARD_RE = /(?:^|[^\d])(?:\d[ -]?){13,19}(?:$|[^\d])/
35
+ const ADDRESS_RE =
36
+ /\b\d{1,6}\s+[\p{L}0-9 .'-]{2,}\s+(street|st|road|rd|avenue|ave|lane|ln|boulevard|blvd|drive|dr|way|court|ct)\b|[\p{Script=Han}]{1,20}(省|市|区|县|路|街|号楼|单元|室)/iu
37
+
38
+ function observePage(args = {}) {
39
+ const mode = args.mode === "fullPage" ? "fullPage" : "viewport"
40
+ const maxElements = clampInteger(args.maxElements, 1, MAX_ELEMENTS, DEFAULT_MAX_ELEMENTS)
41
+ const maxTextLength = clampInteger(args.maxTextLength, 0, MAX_TEXT_LENGTH, DEFAULT_MAX_TEXT_LENGTH)
42
+ const includeHidden = Boolean(args.includeHidden)
43
+ const includeTextTree = args.includeTextTree !== false
44
+ const includeRects = args.includeRects !== false
45
+ const redaction = normalizeRedaction(args.redaction)
46
+ const redactions = {
47
+ mode: redaction,
48
+ count: 0,
49
+ categories: [],
50
+ screenshotRedacted: false,
51
+ }
52
+
53
+ const scope = mode === "fullPage" ? "fullPage" : "viewport"
54
+ const candidates = Array.from(document.querySelectorAll(INTERACTIVE_SELECTOR))
55
+ const elements = []
56
+ const textLines = []
57
+ let uidCounter = 0
58
+
59
+ for (const element of candidates) {
60
+ if (elements.length >= maxElements) break
61
+ if (isYuntiWidgetElement(element)) continue
62
+ const visible = isVisible(element)
63
+ if (!includeHidden && !visible) continue
64
+ if (scope === "viewport" && visible && !isInViewport(element)) continue
65
+ if (scope === "viewport" && !visible && !includeHidden) continue
66
+
67
+ uidCounter += 1
68
+ const uid = `yunti-${uidCounter}`
69
+ const item = describeElement(element, uid, {
70
+ includeRects,
71
+ redaction,
72
+ redactions,
73
+ visible,
74
+ })
75
+ elements.push(item)
76
+ if (includeTextTree) textLines.push(formatTextTreeLine(item))
77
+ }
78
+
79
+ const textTree = includeTextTree ? truncateText(textLines.filter(Boolean).join("\n"), maxTextLength) : undefined
80
+ const scrollableContainers = collectScrollableContainers({
81
+ includeRects,
82
+ maxElements: Math.min(50, maxElements),
83
+ redaction,
84
+ redactions,
85
+ })
86
+ const hints = buildHints(elements, scrollableContainers)
87
+ const warnings = []
88
+ if (redaction === "off") warnings.push("DOM observation redaction is off; use only for explicit local debugging.")
89
+ if (document.readyState === "loading") hints.push("Page is still loading; wait and observe again before acting.")
90
+
91
+ return {
92
+ observationId: `obs-${Date.now()}`,
93
+ browserSessionId: globalScope.__YUNTI_BROWSER_SESSION_ID__ || null,
94
+ capturedAt: new Date().toISOString(),
95
+ uidMapVersion: "observe-v1",
96
+ page: {
97
+ url: redactUrl(location.href, redaction, redactions),
98
+ title: redactTextPreview("page title", document.title || "", 300, { redaction, redactions }),
99
+ origin: location.origin,
100
+ readyState: document.readyState,
101
+ },
102
+ viewport: {
103
+ width: Math.round(globalScope.innerWidth || 0),
104
+ height: Math.round(globalScope.innerHeight || 0),
105
+ devicePixelRatio: Number(globalScope.devicePixelRatio || 1),
106
+ },
107
+ scroll: getDocumentScroll(),
108
+ elements,
109
+ elementCount: elements.length,
110
+ textTree,
111
+ scrollableContainers,
112
+ limits: {
113
+ mode,
114
+ maxElements,
115
+ maxTextLength,
116
+ includeHidden,
117
+ truncatedElements: candidates.length > elements.length,
118
+ truncatedText: Boolean(includeTextTree && textLines.join("\n").length > maxTextLength),
119
+ },
120
+ redactions: finalizeRedactions(redactions),
121
+ hints,
122
+ warnings,
123
+ }
124
+ }
125
+
126
+ function describeElement(element, uid, options) {
127
+ const tag = element.tagName.toLowerCase()
128
+ const role = element.getAttribute("role") || inferRole(element)
129
+ const rawLabel = findLabel(element)
130
+ const rawText = compactText(element.innerText || element.textContent || "", 240)
131
+ const rawName = compactText(
132
+ element.getAttribute("aria-label") ||
133
+ rawLabel ||
134
+ element.getAttribute("title") ||
135
+ element.getAttribute("name") ||
136
+ element.getAttribute("placeholder") ||
137
+ rawText,
138
+ 240
139
+ )
140
+ const label = redactTextPreview("label", rawLabel, 160, options)
141
+ const text = redactTextPreview("text", rawText, 240, options)
142
+ const name = redactTextPreview("name", rawName, 240, options)
143
+ const type = element.getAttribute("type") || (element.isContentEditable ? "contenteditable" : undefined)
144
+ const valueInfo = getValuePreview(element, options.redaction, options.redactions)
145
+ const rect = options.includeRects ? rectInfo(element) : undefined
146
+ const scrollable = isScrollable(element)
147
+ const fieldState = getFieldState(element, options)
148
+
149
+ return cleanObject({
150
+ uid,
151
+ role,
152
+ tag,
153
+ name,
154
+ text,
155
+ label,
156
+ placeholder: safeAttribute(element, "placeholder", options),
157
+ valuePreview: valueInfo.valuePreview,
158
+ valueRedacted: valueInfo.valueRedacted,
159
+ type,
160
+ hrefPreview: element instanceof HTMLAnchorElement ? redactUrl(element.href, options.redaction, options.redactions) : undefined,
161
+ rect,
162
+ visible: options.visible,
163
+ disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
164
+ editable: isEditable(element),
165
+ readOnly: fieldState.readOnly,
166
+ fillable: fieldState.fillable,
167
+ fillBlockReason: fieldState.fillBlockReason,
168
+ selectedIndex: fieldState.selectedIndex,
169
+ selectedValue: fieldState.selectedValue,
170
+ selectedValueRedacted: fieldState.selectedValueRedacted,
171
+ selectedText: fieldState.selectedText,
172
+ options: fieldState.options,
173
+ checked: "checked" in element ? Boolean(element.checked) : undefined,
174
+ selected: "selected" in element ? Boolean(element.selected) : undefined,
175
+ scrollable,
176
+ containerUid: undefined,
177
+ })
178
+ }
179
+
180
+ function collectScrollableContainers(options) {
181
+ const containers = []
182
+ let uidCounter = 0
183
+ for (const element of Array.from(document.querySelectorAll("body *"))) {
184
+ if (containers.length >= options.maxElements) break
185
+ if (isYuntiWidgetElement(element) || !isVisible(element) || !isScrollable(element)) continue
186
+ uidCounter += 1
187
+ containers.push(cleanObject({
188
+ uid: `scroll-${uidCounter}`,
189
+ tag: element.tagName.toLowerCase(),
190
+ name: redactTextPreview(
191
+ "scrollable container name",
192
+ compactText(element.getAttribute("aria-label") || element.getAttribute("title") || element.id || "", 160),
193
+ 160,
194
+ options
195
+ ),
196
+ rect: options.includeRects ? rectInfo(element) : undefined,
197
+ scrollTop: Math.round(element.scrollTop || 0),
198
+ scrollLeft: Math.round(element.scrollLeft || 0),
199
+ scrollHeight: Math.round(element.scrollHeight || 0),
200
+ scrollWidth: Math.round(element.scrollWidth || 0),
201
+ clientHeight: Math.round(element.clientHeight || 0),
202
+ clientWidth: Math.round(element.clientWidth || 0),
203
+ canScrollVertical: element.scrollHeight > element.clientHeight,
204
+ canScrollHorizontal: element.scrollWidth > element.clientWidth,
205
+ pixelsAbove: Math.round(element.scrollTop || 0),
206
+ pixelsBelow: Math.max(0, Math.round((element.scrollHeight || 0) - (element.clientHeight || 0) - (element.scrollTop || 0))),
207
+ pixelsLeft: Math.round(element.scrollLeft || 0),
208
+ pixelsRight: Math.max(0, Math.round((element.scrollWidth || 0) - (element.clientWidth || 0) - (element.scrollLeft || 0))),
209
+ }))
210
+ }
211
+ return containers
212
+ }
213
+
214
+ function getDocumentScroll() {
215
+ const scrollingElement = document.scrollingElement || document.documentElement
216
+ const x = Math.round(globalScope.scrollX || scrollingElement.scrollLeft || 0)
217
+ const y = Math.round(globalScope.scrollY || scrollingElement.scrollTop || 0)
218
+ const viewportWidth = Math.round(globalScope.innerWidth || scrollingElement.clientWidth || 0)
219
+ const viewportHeight = Math.round(globalScope.innerHeight || scrollingElement.clientHeight || 0)
220
+ const pageWidth = Math.round(Math.max(scrollingElement.scrollWidth || 0, document.documentElement.scrollWidth || 0))
221
+ const pageHeight = Math.round(Math.max(scrollingElement.scrollHeight || 0, document.documentElement.scrollHeight || 0))
222
+ const pixelsBelow = Math.max(0, pageHeight - viewportHeight - y)
223
+ const pixelsRight = Math.max(0, pageWidth - viewportWidth - x)
224
+ return {
225
+ x,
226
+ y,
227
+ pageWidth,
228
+ pageHeight,
229
+ pixelsAbove: y,
230
+ pixelsBelow,
231
+ pixelsLeft: x,
232
+ pixelsRight,
233
+ pagesAbove: viewportHeight ? Number((y / viewportHeight).toFixed(2)) : 0,
234
+ pagesBelow: viewportHeight ? Number((pixelsBelow / viewportHeight).toFixed(2)) : 0,
235
+ }
236
+ }
237
+
238
+ function getValuePreview(element, redaction, redactions) {
239
+ if (!("value" in element)) return {}
240
+ const key = [
241
+ element.getAttribute("type"),
242
+ element.getAttribute("name"),
243
+ element.getAttribute("id"),
244
+ element.getAttribute("autocomplete"),
245
+ element.getAttribute("aria-label"),
246
+ element.getAttribute("placeholder"),
247
+ ].filter(Boolean).join(" ")
248
+ const rawValue = String(element.value || "")
249
+ if (!rawValue) return { valuePreview: "", valueRedacted: false }
250
+ if (shouldRedact(key, rawValue, redaction)) {
251
+ recordRedaction(redactions, categoryFor(key, rawValue))
252
+ return { valuePreview: "[REDACTED]", valueRedacted: true }
253
+ }
254
+ return { valuePreview: truncateText(rawValue, 120), valueRedacted: false }
255
+ }
256
+
257
+ function safeAttribute(element, attr, options) {
258
+ const value = element.getAttribute(attr)
259
+ if (!value) return undefined
260
+ const key = `${attr} ${element.getAttribute("name") || ""} ${element.getAttribute("id") || ""}`
261
+ if (shouldRedact(key, value, options.redaction)) {
262
+ recordRedaction(options.redactions, categoryFor(key, value))
263
+ return "[REDACTED]"
264
+ }
265
+ return truncateText(value, 160)
266
+ }
267
+
268
+ function redactTextPreview(key, value, max, options) {
269
+ if (!value) return value
270
+ if (shouldRedact(key, value, options.redaction)) {
271
+ recordRedaction(options.redactions, categoryFor(key, value))
272
+ return "[REDACTED]"
273
+ }
274
+ return truncateText(value, max)
275
+ }
276
+
277
+ function shouldRedact(key, value, redaction) {
278
+ if (redaction === "off") return false
279
+ const haystack = `${key || ""} ${value || ""}`
280
+ if (SENSITIVE_KEY_RE.test(haystack)) return true
281
+ if (JWT_RE.test(haystack) || BEARER_RE.test(haystack) || PRIVATE_KEY_RE.test(haystack)) return true
282
+ if (LONG_RANDOM_RE.test(String(value || ""))) return true
283
+ if (redaction === "strict" && hasStrictPii(value)) return true
284
+ return redaction === "strict" && String(value || "").length >= 16
285
+ }
286
+
287
+ function hasStrictPii(value) {
288
+ const text = String(value || "")
289
+ return EMAIL_RE.test(text) || isPaymentCardLike(text) || PHONE_RE.test(text) || ADDRESS_RE.test(text)
290
+ }
291
+
292
+ function isPaymentCardLike(value) {
293
+ const digits = String(value || "").replace(/\D/g, "")
294
+ return digits.length >= 13 && digits.length <= 19 && PAYMENT_CARD_RE.test(String(value || "")) && passesLuhn(digits)
295
+ }
296
+
297
+ function passesLuhn(digits) {
298
+ let sum = 0
299
+ let shouldDouble = false
300
+ for (let index = digits.length - 1; index >= 0; index -= 1) {
301
+ let digit = Number(digits[index])
302
+ if (shouldDouble) {
303
+ digit *= 2
304
+ if (digit > 9) digit -= 9
305
+ }
306
+ sum += digit
307
+ shouldDouble = !shouldDouble
308
+ }
309
+ return sum % 10 === 0
310
+ }
311
+
312
+ function categoryFor(key, value) {
313
+ const haystack = `${key || ""} ${value || ""}`
314
+ if (/password|passwd|pwd|密码/i.test(haystack)) return "password"
315
+ if (/cookie|session/i.test(haystack)) return "session"
316
+ if (/api[-_]?key|token|secret|auth|jwt|bearer|private[-_]?key|令牌/i.test(haystack)) return "token"
317
+ if (/otp|验证码/i.test(haystack)) return "otp"
318
+ if (EMAIL_RE.test(haystack)) return "email"
319
+ if (isPaymentCardLike(haystack)) return "payment_card"
320
+ if (PHONE_RE.test(haystack)) return "phone"
321
+ if (ADDRESS_RE.test(haystack)) return "address"
322
+ return "credential_like"
323
+ }
324
+
325
+ function recordRedaction(redactions, category) {
326
+ redactions.count += 1
327
+ if (!redactions.categories.includes(category)) redactions.categories.push(category)
328
+ }
329
+
330
+ function finalizeRedactions(redactions) {
331
+ return {
332
+ mode: redactions.mode,
333
+ count: redactions.count,
334
+ categories: redactions.categories.sort(),
335
+ valuesRedacted: redactions.count > 0,
336
+ screenshotRedacted: false,
337
+ }
338
+ }
339
+
340
+ function redactUrl(url, redaction, redactions) {
341
+ if (redaction === "off") return truncateText(url, 2000)
342
+ try {
343
+ const parsed = new URL(url)
344
+ for (const key of Array.from(parsed.searchParams.keys())) {
345
+ const values = parsed.searchParams.getAll(key)
346
+ parsed.searchParams.delete(key)
347
+ for (const value of values) {
348
+ if (shouldRedact(key, value, redaction)) {
349
+ recordRedaction(redactions, categoryFor(key, value))
350
+ parsed.searchParams.append(key, "[REDACTED]")
351
+ } else {
352
+ parsed.searchParams.append(key, truncateText(value, 120))
353
+ }
354
+ }
355
+ }
356
+ return truncateText(parsed.toString(), 2000)
357
+ } catch {
358
+ return truncateText(String(url || ""), 2000)
359
+ }
360
+ }
361
+
362
+ function inferRole(element) {
363
+ const tag = element.tagName.toLowerCase()
364
+ if (tag === "a") return "link"
365
+ if (tag === "button") return "button"
366
+ if (tag === "textarea") return "textbox"
367
+ if (tag === "select") return "combobox"
368
+ if (tag === "input") {
369
+ const type = String(element.getAttribute("type") || "text").toLowerCase()
370
+ if (type === "checkbox") return "checkbox"
371
+ if (type === "radio") return "radio"
372
+ if (type === "range") return "slider"
373
+ if (type === "button" || type === "submit" || type === "reset") return "button"
374
+ return "textbox"
375
+ }
376
+ if (element.isContentEditable) return "textbox"
377
+ return tag
378
+ }
379
+
380
+ function isEditable(element) {
381
+ const tag = element.tagName.toLowerCase()
382
+ if (element.isContentEditable) return true
383
+ if (tag === "select" || tag === "textarea") return true
384
+ if (tag !== "input") return false
385
+ return !isBlockedInputType(element)
386
+ }
387
+
388
+ function getFieldState(element, options) {
389
+ const tag = element.tagName.toLowerCase()
390
+ const disabled = Boolean(element.disabled || element.getAttribute("aria-disabled") === "true")
391
+ const readOnly = Boolean(element.readOnly || element.getAttribute("aria-readonly") === "true")
392
+ const editable = isEditable(element)
393
+ const visible = options.visible !== false
394
+ const fillable = Boolean(visible && editable && !disabled && !readOnly)
395
+ const optionsSummary = tag === "select" ? getSelectOptions(element, options) : undefined
396
+ const selectedSummary = tag === "select" ? getSelectedOptionSummary(element, options) : {}
397
+ return cleanObject({
398
+ readOnly,
399
+ fillable,
400
+ fillBlockReason: fillable ? undefined : getFillBlockReason({ element, visible, editable, disabled, readOnly }),
401
+ ...selectedSummary,
402
+ options: optionsSummary,
403
+ })
404
+ }
405
+
406
+ function getFillBlockReason({ element, visible, editable, disabled, readOnly }) {
407
+ if (!visible) return "hidden-or-not-visible"
408
+ if (disabled) return "disabled"
409
+ if (readOnly) return "readonly"
410
+ if (!editable && isBlockedInputType(element)) return "not-editable-input-type"
411
+ if (!editable) return "not-editable"
412
+ return undefined
413
+ }
414
+
415
+ function isBlockedInputType(element) {
416
+ if (element.tagName.toLowerCase() !== "input") return false
417
+ const type = String(element.getAttribute("type") || "text").toLowerCase()
418
+ return ["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"].includes(type)
419
+ }
420
+
421
+ function getSelectOptions(element, options) {
422
+ const optionElements = Array.from(element.options || [])
423
+ if (!optionElements.length) return undefined
424
+ return optionElements.slice(0, 50).map((option) => {
425
+ const rawValue = String(option.value || "")
426
+ const rawText = compactText(option.text || option.textContent || "", 120)
427
+ const key = `select option ${element.getAttribute("name") || ""} ${element.getAttribute("id") || ""}`
428
+ let value = truncateText(rawValue, 120)
429
+ let valueRedacted = false
430
+ let text = rawText
431
+ let textRedacted = false
432
+ if (shouldRedact(key, rawValue, options.redaction)) {
433
+ recordRedaction(options.redactions, categoryFor(key, rawValue))
434
+ value = "[REDACTED]"
435
+ valueRedacted = true
436
+ }
437
+ if (rawText && shouldRedact(`${key} text`, rawText, options.redaction)) {
438
+ recordRedaction(options.redactions, categoryFor(`${key} text`, rawText))
439
+ text = "[REDACTED]"
440
+ textRedacted = true
441
+ }
442
+ return cleanObject({
443
+ value,
444
+ text,
445
+ selected: Boolean(option.selected),
446
+ disabled: Boolean(option.disabled),
447
+ valueRedacted,
448
+ textRedacted: textRedacted || undefined,
449
+ })
450
+ })
451
+ }
452
+
453
+ function getSelectedOptionSummary(element, options) {
454
+ const optionElements = Array.from(element.options || [])
455
+ const explicitSelectedIndex = Number.isFinite(Number(element.selectedIndex)) ? Number(element.selectedIndex) : -1
456
+ const inferredSelectedIndex = optionElements.findIndex((option) => option.selected)
457
+ const selectedIndex = explicitSelectedIndex >= 0 ? explicitSelectedIndex : inferredSelectedIndex
458
+ const selectedOption = optionElements[selectedIndex] || optionElements.find((option) => option.selected)
459
+ const rawValue = String(element.value || selectedOption?.value || "")
460
+ const rawSelectedText = compactText(selectedOption?.text || selectedOption?.textContent || "", 120)
461
+ const key = `select selected ${element.getAttribute("name") || ""} ${element.getAttribute("id") || ""}`
462
+ let selectedValue = rawValue ? truncateText(rawValue, 120) : ""
463
+ let selectedValueRedacted = false
464
+ let selectedText = rawSelectedText
465
+ let selectedTextRedacted = false
466
+ if (rawValue && shouldRedact(key, rawValue, options.redaction)) {
467
+ recordRedaction(options.redactions, categoryFor(key, rawValue))
468
+ selectedValue = "[REDACTED]"
469
+ selectedValueRedacted = true
470
+ }
471
+ if (rawSelectedText && shouldRedact(`${key} text`, rawSelectedText, options.redaction)) {
472
+ recordRedaction(options.redactions, categoryFor(`${key} text`, rawSelectedText))
473
+ selectedText = "[REDACTED]"
474
+ selectedTextRedacted = true
475
+ }
476
+ return cleanObject({
477
+ selectedIndex,
478
+ selectedValue,
479
+ selectedValueRedacted,
480
+ selectedText,
481
+ selectedTextRedacted: selectedTextRedacted || undefined,
482
+ })
483
+ }
484
+
485
+ function findLabel(element) {
486
+ if (element.id && globalScope.CSS?.escape) {
487
+ const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`)
488
+ if (label) return compactText(label.innerText || label.textContent || "", 160)
489
+ }
490
+ return compactText(element.closest?.("label")?.innerText || element.closest?.("label")?.textContent || "", 160)
491
+ }
492
+
493
+ function formatTextTreeLine(item) {
494
+ const attrs = [
495
+ item.role ? `role=${item.role}` : "",
496
+ item.name ? `name="${item.name}"` : "",
497
+ item.valueRedacted ? "value=[REDACTED]" : item.valuePreview ? `value="${item.valuePreview}"` : "",
498
+ item.disabled ? "disabled" : "",
499
+ ].filter(Boolean).join(" ")
500
+ const tag = item.tag || "element"
501
+ return `[${item.uid}]<${tag}${attrs ? ` ${attrs}` : ""}>${item.text || item.name || ""}</${tag}>`
502
+ }
503
+
504
+ function buildHints(elements, scrollableContainers) {
505
+ const hints = []
506
+ if (!elements.length) hints.push("No visible interactive elements found; wait, scroll, switch tabs, or use screenshot/CDP for recovery.")
507
+ const scroll = getDocumentScroll()
508
+ if (scroll.pixelsBelow > 0) hints.push("Page has content below the viewport; scroll before assuming an element is missing.")
509
+ if (scrollableContainers.length) hints.push("Scrollable containers detected; a later action may need container-aware scrolling.")
510
+ return hints
511
+ }
512
+
513
+ function isYuntiWidgetElement(element) {
514
+ return Boolean(element.closest?.("#yunti-browser-runtime-widget"))
515
+ }
516
+
517
+ function isVisible(element) {
518
+ if (!element || typeof element.getBoundingClientRect !== "function") return false
519
+ const style = globalScope.getComputedStyle ? globalScope.getComputedStyle(element) : null
520
+ if (style && (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0)) return false
521
+ const rect = element.getBoundingClientRect()
522
+ return rect.width > 0 && rect.height > 0
523
+ }
524
+
525
+ function isInViewport(element) {
526
+ const rect = element.getBoundingClientRect()
527
+ const width = globalScope.innerWidth || document.documentElement.clientWidth || 0
528
+ const height = globalScope.innerHeight || document.documentElement.clientHeight || 0
529
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= height && rect.left <= width
530
+ }
531
+
532
+ function isScrollable(element) {
533
+ if (!element) return false
534
+ const style = globalScope.getComputedStyle ? globalScope.getComputedStyle(element) : null
535
+ const overflowY = style?.overflowY || ""
536
+ const overflowX = style?.overflowX || ""
537
+ return (
538
+ (/(auto|scroll)/.test(overflowY) && element.scrollHeight > element.clientHeight) ||
539
+ (/(auto|scroll)/.test(overflowX) && element.scrollWidth > element.clientWidth)
540
+ )
541
+ }
542
+
543
+ function rectInfo(element) {
544
+ const rect = element.getBoundingClientRect()
545
+ return {
546
+ x: Math.round(rect.x),
547
+ y: Math.round(rect.y),
548
+ width: Math.round(rect.width),
549
+ height: Math.round(rect.height),
550
+ }
551
+ }
552
+
553
+ function compactText(text, maxLength) {
554
+ return truncateText(String(text || "").replace(/\s+/g, " ").trim(), maxLength) || undefined
555
+ }
556
+
557
+ function truncateText(text, maxLength) {
558
+ const value = String(text || "")
559
+ return value.length > maxLength ? value.slice(0, maxLength) : value
560
+ }
561
+
562
+ function clampInteger(value, min, max, fallback) {
563
+ const number = Number(value)
564
+ if (!Number.isFinite(number)) return fallback
565
+ return Math.max(min, Math.min(max, Math.floor(number)))
566
+ }
567
+
568
+ function normalizeRedaction(value) {
569
+ const mode = String(value || "balanced")
570
+ return mode === "strict" || mode === "off" ? mode : "balanced"
571
+ }
572
+
573
+ function cleanObject(value) {
574
+ const out = {}
575
+ for (const [key, item] of Object.entries(value)) {
576
+ if (item !== undefined && item !== null && item !== "") out[key] = item
577
+ }
578
+ return out
579
+ }
580
+
581
+ globalScope.YuntiBrowserRuntimeObserver = {
582
+ observePage,
583
+ _private: {
584
+ shouldRedact,
585
+ redactUrl,
586
+ },
587
+ }
588
+ })(globalThis)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Yunti Browser Runtime",
4
- "version": "0.1.3",
4
+ "version": "0.2.0",
5
5
  "description": "Local browser runtime for AI agents through MCP.",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -27,7 +27,7 @@
27
27
  "content_scripts": [
28
28
  {
29
29
  "matches": ["http://*/*", "https://*/*"],
30
- "js": ["content.js"],
30
+ "js": ["dom-observer.js", "content.js"],
31
31
  "css": ["content.css"],
32
32
  "run_at": "document_idle"
33
33
  }
@@ -300,6 +300,7 @@ function normalizeClientInfo(client) {
300
300
  const value = client && typeof client === "object" ? client : {}
301
301
  return {
302
302
  family: normalizeBrowserFamily(value.family || value.userAgent),
303
+ extensionVersion: String(value.extensionVersion || "").slice(0, 80),
303
304
  userAgent: String(value.userAgent || "").slice(0, 500),
304
305
  platform: String(value.platform || "").slice(0, 120),
305
306
  language: String(value.language || "").slice(0, 80),
@@ -308,6 +309,7 @@ function normalizeClientInfo(client) {
308
309
 
309
310
  function getBackgroundClientInfo() {
310
311
  return {
312
+ extensionVersion: chrome.runtime?.getManifest?.().version || "",
311
313
  userAgent: String(globalThis.navigator?.userAgent || ""),
312
314
  platform: String(globalThis.navigator?.platform || ""),
313
315
  language: String(globalThis.navigator?.language || ""),