yunti-browser-runtime 0.1.3 → 0.2.1
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.
- package/README.md +41 -9
- package/bin/yunti-browser-runtime.js +6 -0
- package/docs/ACTION_RESULT_COVERAGE.md +41 -0
- package/docs/AGENT_WORKFLOW_CONTRACT.md +108 -0
- package/docs/EXECUTION_PLAN.md +1433 -5
- package/docs/EXTENSION_DISTRIBUTION.md +146 -0
- package/docs/EXTENSION_PERMISSION_STRATEGY.md +167 -0
- package/docs/EXTENSION_STORE_COPY.md +213 -0
- package/docs/INSTALL.md +13 -7
- package/docs/NEXT_MAJOR_PLAN.md +808 -0
- package/docs/PROJECT_STATUS.md +1037 -7
- package/docs/PUBLISHING_BLOCKERS.md +1 -1
- package/docs/RELEASE.md +6 -2
- package/docs/RELEASE_0_2_0_CHECKLIST.md +827 -0
- package/docs/ROADMAP.md +44 -0
- package/docs/SECURITY.md +34 -0
- package/docs/TOOL_GUIDE.md +290 -5
- package/extension/background.js +20 -0
- package/extension/cdp.js +4 -0
- package/extension/content.js +167 -16
- package/extension/dom-observer.js +588 -0
- package/extension/manifest.json +3 -2
- package/extension/popup.js +2 -2
- package/extension/session-manager.js +106 -4
- package/extension/tool-handlers.js +1090 -156
- package/mcp/bridge-hub.js +257 -3
- package/mcp/http-server.js +213 -0
- package/mcp/memory.js +5 -5
- package/mcp/redaction.js +52 -3
- package/mcp/server.js +2 -0
- package/mcp/tools.js +421 -38
- package/package.json +4 -2
- package/scripts/check-action-result-coverage.js +145 -0
- package/scripts/doctor.js +6 -1
- package/scripts/package-extension.js +1 -0
- package/scripts/release-check.js +3 -0
- package/skills/yunti-browser-runtime/SKILL.md +128 -3
|
@@ -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)
|
package/extension/manifest.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Yunti Browser Runtime",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"description": "Local browser runtime for AI agents through MCP.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
|
8
8
|
"debugger",
|
|
9
|
+
"scripting",
|
|
9
10
|
"storage",
|
|
10
11
|
"tabs",
|
|
11
12
|
"webRequest"
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"content_scripts": [
|
|
28
29
|
{
|
|
29
30
|
"matches": ["http://*/*", "https://*/*"],
|
|
30
|
-
"js": ["content.js"],
|
|
31
|
+
"js": ["dom-observer.js", "content.js"],
|
|
31
32
|
"css": ["content.css"],
|
|
32
33
|
"run_at": "document_idle"
|
|
33
34
|
}
|
package/extension/popup.js
CHANGED
|
@@ -33,7 +33,7 @@ async function save() {
|
|
|
33
33
|
return
|
|
34
34
|
}
|
|
35
35
|
renderSettings(state.settings || {})
|
|
36
|
-
setStatus("
|
|
36
|
+
setStatus("设置已保存。扩展会自动尝试重新注册可访问页面。")
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function renderSettings(settings) {
|
|
@@ -56,7 +56,7 @@ function statusText(state) {
|
|
|
56
56
|
if (!state.bridge?.ok) {
|
|
57
57
|
return "Bridge 未连接。请确认 Agent MCP 已启动,或运行 yunti-browser-runtime bridge。"
|
|
58
58
|
}
|
|
59
|
-
return "
|
|
59
|
+
return "默认无需设置。打开任意 http/https 页面后会自动连接。"
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function setStatus(text) {
|