ghost-bridge 1.0.2 → 1.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 +64 -5
- package/dist/cli.js +0 -0
- package/dist/server.js +260 -89
- package/extension/background.js +453 -147
- package/extension/bg-control.js +52 -0
- package/extension/bg-dom.js +422 -38
- package/extension/bg-runtime.js +17 -0
- package/extension/manifest.json +3 -2
- package/package.json +2 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
(function initGhostBridgeControl(global) {
|
|
2
|
+
async function pollUntil({
|
|
3
|
+
probe,
|
|
4
|
+
timeoutMs,
|
|
5
|
+
overallDeadline = Infinity,
|
|
6
|
+
intervalMs = 200,
|
|
7
|
+
now = () => Date.now(),
|
|
8
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
9
|
+
}) {
|
|
10
|
+
const startedAt = now()
|
|
11
|
+
const conditionDeadline = startedAt + timeoutMs
|
|
12
|
+
let attempts = 0
|
|
13
|
+
let detail
|
|
14
|
+
|
|
15
|
+
while (now() < conditionDeadline && now() < overallDeadline) {
|
|
16
|
+
attempts++
|
|
17
|
+
detail = await probe()
|
|
18
|
+
if (detail?.satisfied) {
|
|
19
|
+
return { satisfied: true, elapsedMs: now() - startedAt, attempts, detail }
|
|
20
|
+
}
|
|
21
|
+
const remaining = Math.min(conditionDeadline, overallDeadline) - now()
|
|
22
|
+
if (remaining > 0) await sleep(Math.min(intervalMs, remaining))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
satisfied: false,
|
|
27
|
+
reason: now() >= overallDeadline ? 'batchTimeout' : 'conditionTimeout',
|
|
28
|
+
elapsedMs: now() - startedAt,
|
|
29
|
+
attempts,
|
|
30
|
+
detail,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runActionBatch(actions, execute, {
|
|
35
|
+
stopOnError = true,
|
|
36
|
+
isTerminalError = (error) => Boolean(error?.batchTimeout),
|
|
37
|
+
mapError = (error, index) => ({ index, success: false, error: error.message }),
|
|
38
|
+
} = {}) {
|
|
39
|
+
const results = []
|
|
40
|
+
for (let index = 0; index < actions.length; index++) {
|
|
41
|
+
try {
|
|
42
|
+
results.push(await execute(actions[index], index))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
results.push(mapError(error, index))
|
|
45
|
+
if (stopOnError || isTerminalError(error)) break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { results, stopped: results.length < actions.length }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
global.GhostBridgeControl = { pollUntil, runActionBatch }
|
|
52
|
+
})(self)
|
package/extension/bg-dom.js
CHANGED
|
@@ -159,7 +159,15 @@
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
function clearRefs(scanTarget) {
|
|
163
|
+
const all = scanTarget.querySelectorAll('*');
|
|
164
|
+
for (const el of all) {
|
|
165
|
+
if (el.hasAttribute('data-ghost-ref')) el.removeAttribute('data-ghost-ref');
|
|
166
|
+
if (el.shadowRoot) clearRefs(el.shadowRoot);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clearRefs(document);
|
|
163
171
|
scanRoot(root);
|
|
164
172
|
|
|
165
173
|
return {
|
|
@@ -203,7 +211,7 @@
|
|
|
203
211
|
})()`
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
function buildPageContentExpression({ mode, selector, maxLength, includeMetadata }) {
|
|
214
|
+
function buildPageContentExpression({ mode, selector, maxLength, offset = 0, includeMetadata }) {
|
|
207
215
|
const selectorStr = selector ? JSON.stringify(selector) : 'null'
|
|
208
216
|
const modeStr = JSON.stringify(mode)
|
|
209
217
|
|
|
@@ -217,6 +225,7 @@
|
|
|
217
225
|
const selector = ${selectorStr};
|
|
218
226
|
const mode = ${modeStr};
|
|
219
227
|
const maxLength = ${maxLength};
|
|
228
|
+
const offset = ${offset};
|
|
220
229
|
const includeMetadata = ${includeMetadata};
|
|
221
230
|
|
|
222
231
|
function getMetadata() {
|
|
@@ -269,28 +278,6 @@
|
|
|
269
278
|
return structured;
|
|
270
279
|
}
|
|
271
280
|
|
|
272
|
-
function smartTruncateText(text, limit) {
|
|
273
|
-
if (text.length <= limit) {
|
|
274
|
-
return { content: text, truncated: false };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (limit < 400) {
|
|
278
|
-
return { content: text.slice(0, limit), truncated: true, note: '内容过长,已截断' };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const headLength = Math.max(200, Math.floor(limit * 0.8));
|
|
282
|
-
const tailLength = Math.max(120, limit - headLength - 80);
|
|
283
|
-
const head = text.slice(0, headLength).trimEnd();
|
|
284
|
-
const tail = text.slice(-tailLength).trimStart();
|
|
285
|
-
const omittedChars = Math.max(0, text.length - head.length - tail.length);
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
content: head + '\\n\\n... [已省略 ' + omittedChars + ' 个字符] ...\\n\\n' + tail,
|
|
289
|
-
truncated: true,
|
|
290
|
-
note: '内容过长,已保留开头与结尾片段'
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
281
|
const targetElement = resolveTargetElement();
|
|
295
282
|
if (targetElement?.error) return targetElement;
|
|
296
283
|
|
|
@@ -299,24 +286,59 @@
|
|
|
299
286
|
}
|
|
300
287
|
|
|
301
288
|
if (mode === 'text') {
|
|
302
|
-
|
|
289
|
+
// 递归收集同源 iframe 内的文本:跨域 iframe 访问 contentDocument 会抛错,跳过即可。
|
|
290
|
+
// 大量文档类页面(钉钉文档、italent 等)正文都在 iframe 里,不递归会拿到空文本,
|
|
291
|
+
// 迫使模型退化为整页截图读文档——那是长会话里最昂贵的 token 开销
|
|
292
|
+
function collectText(el) {
|
|
293
|
+
const collected = {
|
|
294
|
+
text: el.innerText || el.textContent || '',
|
|
295
|
+
iframeCount: 0,
|
|
296
|
+
readableIframeCount: 0,
|
|
297
|
+
crossOriginSkipped: 0
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
var frames = el.querySelectorAll('iframe');
|
|
301
|
+
for (var i = 0; i < frames.length; i++) {
|
|
302
|
+
collected.iframeCount++;
|
|
303
|
+
try {
|
|
304
|
+
var doc = frames[i].contentDocument;
|
|
305
|
+
if (doc && doc.body) {
|
|
306
|
+
const nested = collectText(doc.body);
|
|
307
|
+
collected.readableIframeCount++;
|
|
308
|
+
collected.text += '\\n\\n' + nested.text;
|
|
309
|
+
collected.iframeCount += nested.iframeCount;
|
|
310
|
+
collected.readableIframeCount += nested.readableIframeCount;
|
|
311
|
+
collected.crossOriginSkipped += nested.crossOriginSkipped;
|
|
312
|
+
} else {
|
|
313
|
+
collected.crossOriginSkipped++;
|
|
314
|
+
}
|
|
315
|
+
} catch (e) {
|
|
316
|
+
collected.crossOriginSkipped++;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {}
|
|
320
|
+
return collected;
|
|
321
|
+
}
|
|
322
|
+
const collected = collectText(targetElement);
|
|
323
|
+
let text = collected.text;
|
|
303
324
|
text = text.replace(/\\n{3,}/g, '\\n\\n').trim();
|
|
304
325
|
result.contentLength = text.length;
|
|
305
|
-
|
|
306
|
-
result.
|
|
307
|
-
result.
|
|
308
|
-
|
|
326
|
+
result.iframeCount = collected.iframeCount;
|
|
327
|
+
result.readableIframeCount = collected.readableIframeCount;
|
|
328
|
+
result.crossOriginSkipped = collected.crossOriginSkipped;
|
|
329
|
+
result.includesIframes = collected.readableIframeCount > 0;
|
|
330
|
+
result.offset = offset;
|
|
331
|
+
result.content = text.slice(offset, offset + maxLength);
|
|
332
|
+
result.truncated = offset > 0 || text.length > offset + maxLength;
|
|
333
|
+
result.hasMore = text.length > offset + maxLength;
|
|
309
334
|
} else if (mode === 'html') {
|
|
310
335
|
let html = targetElement.outerHTML || '';
|
|
311
336
|
result.contentLength = html.length;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
result.content = html;
|
|
318
|
-
result.truncated = false;
|
|
319
|
-
}
|
|
337
|
+
result.offset = offset;
|
|
338
|
+
result.content = html.slice(offset, offset + maxLength);
|
|
339
|
+
result.truncated = offset > 0 || html.length > offset + maxLength;
|
|
340
|
+
result.hasMore = html.length > offset + maxLength;
|
|
341
|
+
if (result.truncated) result.note = 'HTML 分页片段可能不是完整标签';
|
|
320
342
|
} else if (mode === 'structured') {
|
|
321
343
|
const structured = buildStructuredContent(targetElement);
|
|
322
344
|
result.structured = structured;
|
|
@@ -404,7 +426,15 @@
|
|
|
404
426
|
}
|
|
405
427
|
}
|
|
406
428
|
|
|
407
|
-
|
|
429
|
+
function clearRefs(scanTarget) {
|
|
430
|
+
const all = scanTarget.querySelectorAll('*');
|
|
431
|
+
for (const el of all) {
|
|
432
|
+
if (el.hasAttribute('data-ghost-ref')) el.removeAttribute('data-ghost-ref');
|
|
433
|
+
if (el.shadowRoot) clearRefs(el.shadowRoot);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
clearRefs(document);
|
|
408
438
|
|
|
409
439
|
let rootEl = document.body;
|
|
410
440
|
const sel = ${selectorStr};
|
|
@@ -433,9 +463,363 @@
|
|
|
433
463
|
})()`
|
|
434
464
|
}
|
|
435
465
|
|
|
466
|
+
// This runtime is serialized into Runtime.evaluate expressions. Keep it self-contained:
|
|
467
|
+
// it intentionally depends only on the supplied page window and standard DOM APIs.
|
|
468
|
+
function createLocatorRuntime(rootWindow) {
|
|
469
|
+
const STORE_KEY = '__ghostActionElements'
|
|
470
|
+
|
|
471
|
+
function normalize(value) {
|
|
472
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function matches(actual, expected, mode) {
|
|
476
|
+
const left = normalize(actual)
|
|
477
|
+
const right = normalize(expected)
|
|
478
|
+
if (!right) return false
|
|
479
|
+
return mode === 'contains' ? left.includes(right) : left === right
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function elementWindow(element) {
|
|
483
|
+
return element?.ownerDocument?.defaultView || rootWindow
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function styleOf(element) {
|
|
487
|
+
try {
|
|
488
|
+
return elementWindow(element).getComputedStyle(element)
|
|
489
|
+
} catch (_) {
|
|
490
|
+
return null
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isVisible(element) {
|
|
495
|
+
if (!element || element.isConnected === false || element.hidden) return false
|
|
496
|
+
if (element.getAttribute?.('aria-hidden') === 'true') return false
|
|
497
|
+
const style = styleOf(element)
|
|
498
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0)) return false
|
|
499
|
+
const rect = element.getBoundingClientRect?.()
|
|
500
|
+
return Boolean(rect && (rect.width > 0 || rect.height > 0))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isDisabled(element) {
|
|
504
|
+
if (!element) return true
|
|
505
|
+
if (element.disabled || element.getAttribute?.('aria-disabled') === 'true') return true
|
|
506
|
+
try {
|
|
507
|
+
return Boolean(element.closest?.('fieldset[disabled]'))
|
|
508
|
+
} catch (_) {
|
|
509
|
+
return false
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function elementText(element) {
|
|
514
|
+
return normalize(element?.innerText || element?.textContent || '')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function rootById(element, id) {
|
|
518
|
+
const root = element?.getRootNode?.()
|
|
519
|
+
if (root?.getElementById) return root.getElementById(id)
|
|
520
|
+
if (root?.querySelector) {
|
|
521
|
+
try {
|
|
522
|
+
return root.querySelector(`[id="${String(id).replace(/["\\]/g, '\\$&')}"]`)
|
|
523
|
+
} catch (_) {}
|
|
524
|
+
}
|
|
525
|
+
return element?.ownerDocument?.getElementById?.(id) || null
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function labelText(element) {
|
|
529
|
+
const parts = []
|
|
530
|
+
try {
|
|
531
|
+
if (element.labels) {
|
|
532
|
+
for (const label of element.labels) parts.push(elementText(label))
|
|
533
|
+
}
|
|
534
|
+
} catch (_) {}
|
|
535
|
+
if (!parts.length) {
|
|
536
|
+
const wrapped = element.closest?.('label')
|
|
537
|
+
if (wrapped) parts.push(elementText(wrapped))
|
|
538
|
+
}
|
|
539
|
+
if (!parts.length && element.id && element.ownerDocument?.querySelectorAll) {
|
|
540
|
+
try {
|
|
541
|
+
for (const label of element.ownerDocument.querySelectorAll('label')) {
|
|
542
|
+
if (label.htmlFor === element.id || label.getAttribute?.('for') === element.id) parts.push(elementText(label))
|
|
543
|
+
}
|
|
544
|
+
} catch (_) {}
|
|
545
|
+
}
|
|
546
|
+
return normalize(parts.filter(Boolean).join(' '))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function accessibleName(element) {
|
|
550
|
+
const ariaLabel = normalize(element.getAttribute?.('aria-label'))
|
|
551
|
+
if (ariaLabel) return ariaLabel
|
|
552
|
+
|
|
553
|
+
const labelledBy = normalize(element.getAttribute?.('aria-labelledby'))
|
|
554
|
+
if (labelledBy) {
|
|
555
|
+
const text = labelledBy.split(' ').map((id) => elementText(rootById(element, id))).filter(Boolean).join(' ')
|
|
556
|
+
if (text) return normalize(text)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const label = labelText(element)
|
|
560
|
+
if (label) return label
|
|
561
|
+
const alt = normalize(element.getAttribute?.('alt'))
|
|
562
|
+
if (alt) return alt
|
|
563
|
+
const tag = String(element.tagName || '').toLowerCase()
|
|
564
|
+
const type = String(element.type || '').toLowerCase()
|
|
565
|
+
if (tag === 'input' && ['button', 'submit', 'reset'].includes(type)) {
|
|
566
|
+
const value = normalize(element.value)
|
|
567
|
+
if (value) return value
|
|
568
|
+
}
|
|
569
|
+
return elementText(element)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function implicitRole(element) {
|
|
573
|
+
const explicit = normalize(element.getAttribute?.('role')).toLowerCase()
|
|
574
|
+
if (explicit) return explicit.split(' ')[0]
|
|
575
|
+
const tag = String(element.tagName || '').toLowerCase()
|
|
576
|
+
const type = String(element.type || '').toLowerCase()
|
|
577
|
+
if (tag === 'button') return 'button'
|
|
578
|
+
if (tag === 'a' && element.getAttribute?.('href')) return 'link'
|
|
579
|
+
if (tag === 'textarea') return 'textbox'
|
|
580
|
+
if (tag === 'select') return element.multiple ? 'listbox' : 'combobox'
|
|
581
|
+
if (tag === 'img') return 'img'
|
|
582
|
+
if (/^h[1-6]$/.test(tag)) return 'heading'
|
|
583
|
+
if (tag === 'input') {
|
|
584
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button'
|
|
585
|
+
if (type === 'checkbox') return 'checkbox'
|
|
586
|
+
if (type === 'radio') return 'radio'
|
|
587
|
+
if (type === 'range') return 'slider'
|
|
588
|
+
if (type === 'number') return 'spinbutton'
|
|
589
|
+
if (!['hidden', 'color', 'file'].includes(type)) return type === 'search' ? 'searchbox' : 'textbox'
|
|
590
|
+
}
|
|
591
|
+
return ''
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function collectContexts() {
|
|
595
|
+
const contexts = []
|
|
596
|
+
const seenRoots = new Set()
|
|
597
|
+
|
|
598
|
+
function visit(root, frames) {
|
|
599
|
+
if (!root || seenRoots.has(root)) return
|
|
600
|
+
seenRoots.add(root)
|
|
601
|
+
contexts.push({ root, frames })
|
|
602
|
+
let elements = []
|
|
603
|
+
try { elements = Array.from(root.querySelectorAll('*')) } catch (_) {}
|
|
604
|
+
for (const element of elements) {
|
|
605
|
+
if (element.shadowRoot) visit(element.shadowRoot, frames)
|
|
606
|
+
if (String(element.tagName || '').toLowerCase() === 'iframe') {
|
|
607
|
+
try {
|
|
608
|
+
const frameDocument = element.contentDocument
|
|
609
|
+
if (frameDocument?.documentElement) visit(frameDocument, frames.concat(element))
|
|
610
|
+
} catch (_) {}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
visit(rootWindow.document, [])
|
|
616
|
+
return contexts
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function semanticMatch(element, locator, mode) {
|
|
620
|
+
if (locator.testId !== undefined && !matches(element.getAttribute?.('data-testid'), locator.testId, mode)) return false
|
|
621
|
+
if (locator.role !== undefined && normalize(implicitRole(element)).toLowerCase() !== normalize(locator.role).toLowerCase()) return false
|
|
622
|
+
if (locator.name !== undefined && !matches(accessibleName(element), locator.name, mode)) return false
|
|
623
|
+
if (locator.label !== undefined) {
|
|
624
|
+
const tag = String(element.tagName || '').toLowerCase()
|
|
625
|
+
if (!['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea'].includes(tag)) return false
|
|
626
|
+
if (!matches(labelText(element) || accessibleName(element), locator.label, mode)) return false
|
|
627
|
+
}
|
|
628
|
+
if (locator.placeholder !== undefined && !matches(element.getAttribute?.('placeholder') || element.placeholder, locator.placeholder, mode)) return false
|
|
629
|
+
if (locator.text !== undefined) {
|
|
630
|
+
if (!matches(elementText(element), locator.text, mode)) return false
|
|
631
|
+
try {
|
|
632
|
+
const childMatches = Array.from(element.querySelectorAll('*')).some((child) => matches(elementText(child), locator.text, mode))
|
|
633
|
+
if (childMatches) return false
|
|
634
|
+
} catch (_) {}
|
|
635
|
+
}
|
|
636
|
+
return true
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function find(locator = {}, { visibleOnly = false } = {}) {
|
|
640
|
+
if (!locator || typeof locator !== 'object') return { error: 'locator 必须是对象' }
|
|
641
|
+
const keys = ['css', 'testId', 'role', 'name', 'label', 'placeholder', 'text']
|
|
642
|
+
if (!keys.some((key) => locator[key] !== undefined && locator[key] !== '')) {
|
|
643
|
+
return { error: 'locator 至少需要 css/testId/role/name/label/placeholder/text 之一' }
|
|
644
|
+
}
|
|
645
|
+
if (locator.match && !['exact', 'contains'].includes(locator.match)) return { error: 'locator.match 仅支持 exact 或 contains' }
|
|
646
|
+
if (locator.nth !== undefined && (!Number.isInteger(locator.nth) || locator.nth < 0)) return { error: 'locator.nth 必须是从 0 开始的整数' }
|
|
647
|
+
|
|
648
|
+
const mode = locator.match || 'exact'
|
|
649
|
+
const found = []
|
|
650
|
+
const seen = new Set()
|
|
651
|
+
for (const context of collectContexts()) {
|
|
652
|
+
let elements = []
|
|
653
|
+
try {
|
|
654
|
+
elements = Array.from(context.root.querySelectorAll(locator.css || '*'))
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return { error: `无效的 CSS 选择器: ${error.message}` }
|
|
657
|
+
}
|
|
658
|
+
for (const element of elements) {
|
|
659
|
+
if (seen.has(element)) continue
|
|
660
|
+
seen.add(element)
|
|
661
|
+
if (!semanticMatch(element, locator, mode)) continue
|
|
662
|
+
if (visibleOnly && !isVisible(element)) continue
|
|
663
|
+
found.push({ element, frames: context.frames })
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return { found }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function summary(item, index) {
|
|
670
|
+
const element = item.element
|
|
671
|
+
const result = {
|
|
672
|
+
nth: index,
|
|
673
|
+
tag: String(element.tagName || '').toLowerCase(),
|
|
674
|
+
role: implicitRole(element) || undefined,
|
|
675
|
+
name: accessibleName(element).slice(0, 100) || undefined,
|
|
676
|
+
text: elementText(element).slice(0, 100) || undefined,
|
|
677
|
+
placeholder: normalize(element.getAttribute?.('placeholder') || element.placeholder).slice(0, 80) || undefined,
|
|
678
|
+
testId: normalize(element.getAttribute?.('data-testid')).slice(0, 80) || undefined,
|
|
679
|
+
disabled: isDisabled(element) || undefined,
|
|
680
|
+
}
|
|
681
|
+
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined && value !== ''))
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function selectOne(locator, options) {
|
|
685
|
+
const result = find(locator, options)
|
|
686
|
+
if (result.error) return result
|
|
687
|
+
const matches = result.found
|
|
688
|
+
if (!matches.length) return { error: 'locator 未匹配到元素', locator }
|
|
689
|
+
if (locator.nth !== undefined) {
|
|
690
|
+
if (!matches[locator.nth]) return { error: `locator.nth=${locator.nth} 超出匹配范围(共 ${matches.length} 个)`, locator }
|
|
691
|
+
return { selected: matches[locator.nth], count: matches.length }
|
|
692
|
+
}
|
|
693
|
+
if (matches.length > 1) {
|
|
694
|
+
return {
|
|
695
|
+
error: `locator 匹配到 ${matches.length} 个元素,请增加条件或指定 nth`,
|
|
696
|
+
ambiguous: true,
|
|
697
|
+
matchCount: matches.length,
|
|
698
|
+
candidates: matches.slice(0, 5).map(summary),
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { selected: matches[0], count: 1 }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function scrollAndMeasure(item) {
|
|
705
|
+
for (const frame of item.frames) frame.scrollIntoView?.({ block: 'center', inline: 'center' })
|
|
706
|
+
item.element.scrollIntoView?.({ block: 'center', inline: 'center' })
|
|
707
|
+
const rect = item.element.getBoundingClientRect()
|
|
708
|
+
let left = rect.left
|
|
709
|
+
let top = rect.top
|
|
710
|
+
for (const frame of item.frames) {
|
|
711
|
+
const frameRect = frame.getBoundingClientRect()
|
|
712
|
+
left += frameRect.left + (frame.clientLeft || 0)
|
|
713
|
+
top += frameRect.top + (frame.clientTop || 0)
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
cx: Math.round(left + rect.width / 2),
|
|
717
|
+
cy: Math.round(top + rect.height / 2),
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function locate(locator, actionId) {
|
|
722
|
+
const result = selectOne(locator, { visibleOnly: true })
|
|
723
|
+
if (result.error) return result
|
|
724
|
+
const item = result.selected
|
|
725
|
+
const position = scrollAndMeasure(item)
|
|
726
|
+
if (!isVisible(item.element)) return { error: '元素滚动后仍不可见', locator }
|
|
727
|
+
if (!rootWindow[STORE_KEY]) rootWindow[STORE_KEY] = Object.create(null)
|
|
728
|
+
rootWindow[STORE_KEY][actionId] = item
|
|
729
|
+
return {
|
|
730
|
+
found: true,
|
|
731
|
+
...summary(item, locator.nth || 0),
|
|
732
|
+
...position,
|
|
733
|
+
matchCount: result.count,
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function probe(locator, state) {
|
|
738
|
+
const result = find(locator, { visibleOnly: false })
|
|
739
|
+
if (result.error) return result
|
|
740
|
+
const all = result.found
|
|
741
|
+
const selected = locator.nth === undefined ? all : (all[locator.nth] ? [all[locator.nth]] : [])
|
|
742
|
+
const visible = selected.filter((item) => isVisible(item.element))
|
|
743
|
+
const enabled = visible.filter((item) => !isDisabled(item.element))
|
|
744
|
+
let satisfied = false
|
|
745
|
+
if (state === 'attached') satisfied = selected.length > 0
|
|
746
|
+
else if (state === 'detached') satisfied = selected.length === 0
|
|
747
|
+
else if (state === 'hidden') satisfied = visible.length === 0
|
|
748
|
+
else if (state === 'enabled') satisfied = enabled.length > 0
|
|
749
|
+
else satisfied = visible.length > 0
|
|
750
|
+
return {
|
|
751
|
+
satisfied,
|
|
752
|
+
state,
|
|
753
|
+
matchCount: all.length,
|
|
754
|
+
visibleCount: visible.length,
|
|
755
|
+
enabledCount: enabled.length,
|
|
756
|
+
candidates: satisfied ? undefined : all.slice(0, 3).map(summary),
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function use(actionId, command, payload = {}) {
|
|
761
|
+
const store = rootWindow[STORE_KEY]
|
|
762
|
+
const item = store?.[actionId]
|
|
763
|
+
const element = item?.element
|
|
764
|
+
if (!element || element.isConnected === false) return { error: '动作执行前元素已从页面移除' }
|
|
765
|
+
if (command === 'focus') element.focus?.()
|
|
766
|
+
else if (command === 'prepareFill') {
|
|
767
|
+
element.focus?.()
|
|
768
|
+
element.select?.()
|
|
769
|
+
} else if (command === 'dispatchInput') {
|
|
770
|
+
element.dispatchEvent(new (elementWindow(element).Event)('input', { bubbles: true }))
|
|
771
|
+
element.dispatchEvent(new (elementWindow(element).Event)('change', { bubbles: true }))
|
|
772
|
+
} else if (command === 'select') {
|
|
773
|
+
if (String(element.tagName || '').toLowerCase() !== 'select') return { error: 'select 动作只能用于 <select> 元素' }
|
|
774
|
+
const values = Array.from(element.options || []).map((option) => String(option.value))
|
|
775
|
+
if (!values.includes(String(payload.value))) return { error: `下拉框不存在值 "${String(payload.value)}"` }
|
|
776
|
+
element.value = String(payload.value)
|
|
777
|
+
element.dispatchEvent(new (elementWindow(element).Event)('input', { bubbles: true }))
|
|
778
|
+
element.dispatchEvent(new (elementWindow(element).Event)('change', { bubbles: true }))
|
|
779
|
+
}
|
|
780
|
+
return { success: true }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function cleanup(actionId) {
|
|
784
|
+
if (rootWindow[STORE_KEY]) delete rootWindow[STORE_KEY][actionId]
|
|
785
|
+
return true
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return { version: 1, locate, probe, use, cleanup }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const locatorRuntimeSource = () => `(${createLocatorRuntime.toString()})(window)`
|
|
792
|
+
|
|
793
|
+
function buildInstallLocatorRuntimeExpression() {
|
|
794
|
+
return `(function(){window.__ghostLocatorRuntime=${locatorRuntimeSource()};return true;})()`
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function buildLocateElementExpression({ locator, ref, selector, actionId }) {
|
|
798
|
+
const semanticLocator = locator || { css: ref ? `[data-ghost-ref="${String(ref).replace(/["\\]/g, '\\$&')}"]` : String(selector || '') }
|
|
799
|
+
return `(function(){try{return window.__ghostLocatorRuntime.locate(${JSON.stringify(semanticLocator)},${JSON.stringify(actionId)});}catch(e){return {error:e.message};}})()`
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function buildElementCommandExpression({ actionId, command, payload }) {
|
|
803
|
+
return `(function(){try{return window.__ghostLocatorRuntime.use(${JSON.stringify(actionId)},${JSON.stringify(command)},${JSON.stringify(payload || {})});}catch(e){return {error:e.message};}})()`
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function buildCleanupElementExpression(actionId) {
|
|
807
|
+
return `(function(){return window.__ghostLocatorRuntime ? window.__ghostLocatorRuntime.cleanup(${JSON.stringify(actionId)}) : true;})()`
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function buildLocatorProbeExpression({ locator, state }) {
|
|
811
|
+
return `(function(){try{return window.__ghostLocatorRuntime.probe(${JSON.stringify(locator)},${JSON.stringify(state)});}catch(e){return {error:e.message};}})()`
|
|
812
|
+
}
|
|
813
|
+
|
|
436
814
|
global.GhostBridgeDom = {
|
|
437
815
|
buildInspectPageExpression,
|
|
438
816
|
buildPageContentExpression,
|
|
439
817
|
buildInteractiveSnapshotExpression,
|
|
818
|
+
buildInstallLocatorRuntimeExpression,
|
|
819
|
+
buildLocateElementExpression,
|
|
820
|
+
buildElementCommandExpression,
|
|
821
|
+
buildCleanupElementExpression,
|
|
822
|
+
buildLocatorProbeExpression,
|
|
823
|
+
createLocatorRuntime,
|
|
440
824
|
}
|
|
441
825
|
})(self)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
(function initGhostBridgeRuntime(global) {
|
|
2
|
+
async function evaluateScript({ sendCommand, target, code, awaitPromise = true, timeoutMs, withTimeout, label = 'eval_script' }) {
|
|
3
|
+
const command = sendCommand(target, 'Runtime.evaluate', {
|
|
4
|
+
expression: code,
|
|
5
|
+
returnByValue: true,
|
|
6
|
+
awaitPromise,
|
|
7
|
+
timeout: timeoutMs,
|
|
8
|
+
})
|
|
9
|
+
const { result, exceptionDetails } = await withTimeout(command, timeoutMs + 1000, label)
|
|
10
|
+
if (exceptionDetails) {
|
|
11
|
+
throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || '脚本执行失败')
|
|
12
|
+
}
|
|
13
|
+
return result?.value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
global.GhostBridgeRuntime = { evaluateScript }
|
|
17
|
+
})(self)
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Ghost Bridge",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"description": "Zero-restart Chrome debugger bridge for Claude MCP, optimized for no-sourcemap production debugging.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"storage",
|
|
11
11
|
"tabs",
|
|
12
12
|
"offscreen",
|
|
13
|
-
"idle"
|
|
13
|
+
"idle",
|
|
14
|
+
"alarms"
|
|
14
15
|
],
|
|
15
16
|
"host_permissions": [
|
|
16
17
|
"ws://localhost/*",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghost-bridge",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts": {
|
|
39
39
|
"start": "node dist/server.js",
|
|
40
40
|
"build": "node scripts/build.js",
|
|
41
|
+
"test": "node --test",
|
|
41
42
|
"prepublishOnly": "npm run build"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|