yunti-browser-runtime 0.1.2 → 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.
- package/README.md +52 -15
- package/bin/yunti-browser-runtime.js +6 -0
- package/docs/ACTION_RESULT_COVERAGE.md +41 -0
- package/docs/AGENT_WORKFLOW_CONTRACT.md +97 -0
- package/docs/EXECUTION_PLAN.md +1433 -4
- package/docs/EXTENSION_DISTRIBUTION.md +145 -0
- package/docs/EXTENSION_PERMISSION_STRATEGY.md +164 -0
- package/docs/EXTENSION_STORE_COPY.md +210 -0
- package/docs/INSTALL.md +18 -8
- package/docs/NEXT_MAJOR_PLAN.md +808 -0
- package/docs/PROJECT_STATUS.md +1019 -8
- package/docs/PUBLISHING_BLOCKERS.md +2 -2
- package/docs/RELEASE.md +6 -2
- package/docs/RELEASE_0_2_0_CHECKLIST.md +820 -0
- package/docs/ROADMAP.md +44 -0
- package/docs/SECURITY.md +34 -0
- package/docs/TOOL_GUIDE.md +282 -5
- package/extension/content.js +76 -7
- package/extension/dom-observer.js +588 -0
- package/extension/manifest.json +2 -2
- package/extension/popup.css +6 -1
- package/extension/popup.html +10 -10
- package/extension/popup.js +21 -10
- package/extension/session-manager.js +2 -0
- package/extension/tool-handlers.js +1120 -94
- 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 +417 -38
- package/package.json +4 -2
- package/scripts/check-action-result-coverage.js +145 -0
- package/scripts/doctor.js +4 -0
- package/scripts/package-extension.js +1 -0
- package/scripts/print-config.js +1 -1
- package/scripts/release-check.js +3 -0
- package/skills/yunti-browser-runtime/SKILL.md +125 -2
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Yunti Browser Runtime",
|
|
4
|
-
"version": "0.
|
|
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
|
}
|
package/extension/popup.css
CHANGED
|
@@ -50,6 +50,11 @@ details label {
|
|
|
50
50
|
margin-top: 6px;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
details button {
|
|
54
|
+
width: 100%;
|
|
55
|
+
margin-top: 6px;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
input,
|
|
54
59
|
textarea {
|
|
55
60
|
width: 100%;
|
|
@@ -75,7 +80,7 @@ button {
|
|
|
75
80
|
font: 500 13px/1 system-ui;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
button
|
|
83
|
+
main > button {
|
|
79
84
|
border-color: #0f766e;
|
|
80
85
|
background: #0f766e;
|
|
81
86
|
color: white;
|
package/extension/popup.html
CHANGED
|
@@ -10,23 +10,23 @@
|
|
|
10
10
|
<main>
|
|
11
11
|
<h1>Yunti Browser Runtime</h1>
|
|
12
12
|
<p id="status">正在读取状态...</p>
|
|
13
|
-
<
|
|
14
|
-
Bridge URL
|
|
15
|
-
<input id="bridgeUrl" autocomplete="off" />
|
|
16
|
-
</label>
|
|
17
|
-
<label>
|
|
18
|
-
页面匹配
|
|
19
|
-
<textarea id="platformMatches" rows="3"></textarea>
|
|
20
|
-
</label>
|
|
13
|
+
<button id="refresh">刷新状态</button>
|
|
21
14
|
<details id="advancedSettings">
|
|
22
15
|
<summary>高级设置</summary>
|
|
16
|
+
<label>
|
|
17
|
+
Bridge URL
|
|
18
|
+
<input id="bridgeUrl" autocomplete="off" />
|
|
19
|
+
</label>
|
|
20
|
+
<label>
|
|
21
|
+
页面匹配
|
|
22
|
+
<textarea id="platformMatches" rows="3"></textarea>
|
|
23
|
+
</label>
|
|
23
24
|
<label>
|
|
24
25
|
Bridge Token(可选)
|
|
25
26
|
<input id="bridgeToken" autocomplete="off" type="password" />
|
|
26
27
|
</label>
|
|
28
|
+
<button id="save">保存设置</button>
|
|
27
29
|
</details>
|
|
28
|
-
<button id="save">保存设置</button>
|
|
29
|
-
<button id="refresh">刷新状态</button>
|
|
30
30
|
</main>
|
|
31
31
|
<script type="module" src="popup.js"></script>
|
|
32
32
|
</body>
|