dsh-sidebar-browser-control 0.1.0 → 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 +58 -12
- package/README.zh.md +13 -5
- package/client.js +611 -29
- package/index.js +361 -10
- package/package.json +2 -2
package/client.js
CHANGED
|
@@ -206,9 +206,9 @@ window.__ModuleLoader__.load({
|
|
|
206
206
|
return undefined;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/** @param frame - a carrier element. @returns whether it is the Electron webview. */
|
|
209
|
+
/** @param frame - a carrier element, possibly absent. @returns whether it is the Electron webview. */
|
|
210
210
|
function isWebview(frame) {
|
|
211
|
-
return frame
|
|
211
|
+
return frame !== undefined && frame !== null && typeof frame.tagName === 'string' && frame.tagName.toLowerCase() === 'webview';
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/** @param frame - a carrier element. @returns its current address, when readable. */
|
|
@@ -217,10 +217,11 @@ window.__ModuleLoader__.load({
|
|
|
217
217
|
try {
|
|
218
218
|
if (isWebview(frame)) {
|
|
219
219
|
const url = frame.getURL();
|
|
220
|
-
|
|
220
|
+
/* A guest is created at `about:blank#<lease>`; that is not an address. */
|
|
221
|
+
return typeof url === 'string' && /^https?:/u.test(url) ? url : undefined;
|
|
221
222
|
}
|
|
222
223
|
const src = frame.getAttribute('src');
|
|
223
|
-
return src === null ||
|
|
224
|
+
return src === null || !/^https?:/u.test(src) ? undefined : new URL(src, location.href).href;
|
|
224
225
|
} catch {
|
|
225
226
|
return undefined;
|
|
226
227
|
}
|
|
@@ -254,11 +255,31 @@ window.__ModuleLoader__.load({
|
|
|
254
255
|
return pane?.getAttribute('data-dockkit-pane') ?? pane?.getAttribute('data-dockkit-float') ?? undefined;
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
/**
|
|
259
|
+
* What this page can actually do about pixels.
|
|
260
|
+
*
|
|
261
|
+
* Screenshots are the one capability with a native failure mode, so the
|
|
262
|
+
* answer is reported instead of guessed: `guest` is the webview's own
|
|
263
|
+
* `capturePage` (crashed the renderer once — see docs/architecture.md),
|
|
264
|
+
* `window` needs a renderer capture API, `dom` is the pure-JS fallback.
|
|
265
|
+
* @returns a diagnostic snapshot of the available capture routes.
|
|
266
|
+
*/
|
|
267
|
+
function captureRoutes() {
|
|
268
|
+
const carrier = globalThis.dshDesktop;
|
|
269
|
+
return {
|
|
270
|
+
guest: typeof document.createElement('webview').capturePage === 'function',
|
|
271
|
+
desktop_capturer: typeof globalThis.desktopCapturer?.getSources === 'function',
|
|
272
|
+
display_media: typeof navigator.mediaDevices?.getDisplayMedia === 'function',
|
|
273
|
+
carrier_keys: carrier === undefined || carrier === null ? [] : Object.keys(carrier).slice(0, 20)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
/** @returns every Browser tab, with the addresses its carrier can report. */
|
|
258
278
|
function listTabs() {
|
|
259
279
|
const service = sidebar();
|
|
260
280
|
const screen = onScreenSession();
|
|
261
281
|
const active = activeTabEntry();
|
|
282
|
+
const capture = captureRoutes();
|
|
262
283
|
const tabs = browserEntries().map((entry) => {
|
|
263
284
|
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
264
285
|
const record = recordFor(entry);
|
|
@@ -277,6 +298,7 @@ window.__ModuleLoader__.load({
|
|
|
277
298
|
page_control: isDesktop(),
|
|
278
299
|
panel_open: service.isExpanded?.() === true,
|
|
279
300
|
active_tab: active?.handle,
|
|
301
|
+
capture,
|
|
280
302
|
tabs
|
|
281
303
|
};
|
|
282
304
|
}
|
|
@@ -294,7 +316,7 @@ window.__ModuleLoader__.load({
|
|
|
294
316
|
}
|
|
295
317
|
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
296
318
|
if (frame === undefined) {
|
|
297
|
-
throw new Error(`标签页 ${entry.handle}
|
|
319
|
+
throw new Error(`标签页 ${entry.handle} 当前没有已挂载的页面:它可能是重启后尚未加载、在别的会话、或已收起。用 browser_control 的 navigate 指定地址即可加载它(reload 可重载已保存的地址)。`);
|
|
298
320
|
}
|
|
299
321
|
return { entry, frame, tabId: entry.tabId, sessionId: entry.sessionId };
|
|
300
322
|
}
|
|
@@ -384,6 +406,108 @@ window.__ModuleLoader__.load({
|
|
|
384
406
|
return `${text.slice(0, limit)}…(已截断,共 ${text.length} 字符)`;
|
|
385
407
|
}
|
|
386
408
|
|
|
409
|
+
/**
|
|
410
|
+
* In-page helpers shared by every form-aware action.
|
|
411
|
+
*
|
|
412
|
+
* Login forms are the reason these exist: fields have to be found by label,
|
|
413
|
+
* name, placeholder or autocomplete rather than by a hand-written selector,
|
|
414
|
+
* and the form itself may live in a same-origin iframe.
|
|
415
|
+
* @returns JavaScript source declaring `visible`, `docs`, `labelled`,
|
|
416
|
+
* `fields`, `findField`, `setValue` and `submitForm`.
|
|
417
|
+
*/
|
|
418
|
+
function pageHelpers() {
|
|
419
|
+
return `
|
|
420
|
+
const visible = (el) => { const rect = el.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; };
|
|
421
|
+
const docs = () => {
|
|
422
|
+
const list = [document];
|
|
423
|
+
for (const frame of document.querySelectorAll('iframe')) {
|
|
424
|
+
try { if (frame.contentDocument !== null) list.push(frame.contentDocument); } catch (error) { /* cross-origin */ }
|
|
425
|
+
}
|
|
426
|
+
return list;
|
|
427
|
+
};
|
|
428
|
+
const labelled = (el) => {
|
|
429
|
+
const parts = [];
|
|
430
|
+
if (el.id !== undefined && el.id.length > 0) {
|
|
431
|
+
const root = el.ownerDocument;
|
|
432
|
+
const label = root.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]');
|
|
433
|
+
if (label !== null) parts.push(label.innerText);
|
|
434
|
+
}
|
|
435
|
+
const wrapping = el.closest === undefined ? null : el.closest('label');
|
|
436
|
+
if (wrapping !== null && wrapping !== undefined) parts.push(wrapping.innerText);
|
|
437
|
+
for (const attribute of ['aria-label', 'placeholder', 'title', 'name', 'id']) {
|
|
438
|
+
const value = el.getAttribute === undefined ? null : el.getAttribute(attribute);
|
|
439
|
+
if (typeof value === 'string' && value.length > 0) parts.push(value);
|
|
440
|
+
}
|
|
441
|
+
return parts.join(' ').replace(/\\s+/g, ' ').trim().slice(0, 160);
|
|
442
|
+
};
|
|
443
|
+
const fields = () => {
|
|
444
|
+
const list = [];
|
|
445
|
+
for (const doc of docs()) {
|
|
446
|
+
for (const el of doc.querySelectorAll('input,textarea,select')) {
|
|
447
|
+
if (el.type === 'hidden' || !visible(el)) continue;
|
|
448
|
+
list.push(el);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return list;
|
|
452
|
+
};
|
|
453
|
+
const describe = (el) => ({
|
|
454
|
+
tag: el.tagName.toLowerCase(),
|
|
455
|
+
type: (el.getAttribute('type') || el.tagName.toLowerCase()).toLowerCase(),
|
|
456
|
+
name: el.getAttribute('name') || undefined,
|
|
457
|
+
id: el.id || undefined,
|
|
458
|
+
autocomplete: el.getAttribute('autocomplete') || undefined,
|
|
459
|
+
placeholder: el.getAttribute('placeholder') || undefined,
|
|
460
|
+
label: labelled(el),
|
|
461
|
+
value: el.type === 'password' ? '' : String(el.value ?? '').slice(0, 60),
|
|
462
|
+
checked: el.checked === undefined ? undefined : el.checked
|
|
463
|
+
});
|
|
464
|
+
/* Resolve one field description to an element: explicit selector, then
|
|
465
|
+
the semantic signals a login form actually carries. */
|
|
466
|
+
const findField = (spec) => {
|
|
467
|
+
const pool = fields();
|
|
468
|
+
const selector = typeof spec.selector === 'string' ? spec.selector : '';
|
|
469
|
+
if (selector.length > 0) {
|
|
470
|
+
for (const doc of docs()) {
|
|
471
|
+
const found = doc.querySelector(selector);
|
|
472
|
+
if (found !== null) return found;
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const wanted = String(spec.name || spec.label || spec.placeholder || spec.type || '').toLowerCase();
|
|
477
|
+
if (wanted.length === 0) return null;
|
|
478
|
+
const scored = pool.map((el) => {
|
|
479
|
+
const info = describe(el);
|
|
480
|
+
const haystack = [info.name, info.id, info.placeholder, info.label, info.autocomplete, info.type]
|
|
481
|
+
.filter((part) => typeof part === 'string').join(' ').toLowerCase();
|
|
482
|
+
let score = 0;
|
|
483
|
+
if (info.type === wanted) score += 4;
|
|
484
|
+
if (info.name !== undefined && info.name.toLowerCase() === wanted) score += 8;
|
|
485
|
+
if (haystack.includes(wanted)) score += 3;
|
|
486
|
+
if (wanted === 'password' && info.type === 'password') score += 10;
|
|
487
|
+
if (wanted === 'username' && /user|email|phone|account|login|mobile/i.test(haystack)) score += 6;
|
|
488
|
+
return { el, score };
|
|
489
|
+
}).filter((candidate) => candidate.score > 0);
|
|
490
|
+
scored.sort((left, right) => right.score - left.score);
|
|
491
|
+
return scored.length === 0 ? null : scored[0].el;
|
|
492
|
+
};
|
|
493
|
+
const setValue = (el, value) => {
|
|
494
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
495
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
496
|
+
if (setter !== undefined) setter.call(el, value); else el.value = value;
|
|
497
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
498
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
499
|
+
};
|
|
500
|
+
const submitForm = (el) => {
|
|
501
|
+
const form = el.form ?? (el.closest === undefined ? null : el.closest('form'));
|
|
502
|
+
if (form !== null && form !== undefined && typeof form.requestSubmit === 'function') { form.requestSubmit(); return true; }
|
|
503
|
+
const button = el.ownerDocument.querySelector('button[type=submit],input[type=submit],button:not([type])');
|
|
504
|
+
if (button !== null && button !== undefined) { button.click(); return true; }
|
|
505
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
|
506
|
+
return false;
|
|
507
|
+
};
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
|
|
387
511
|
/**
|
|
388
512
|
* Build the in-page expression that resolves a click/wait target.
|
|
389
513
|
* @param selector - optional CSS selector.
|
|
@@ -395,12 +519,27 @@ window.__ModuleLoader__.load({
|
|
|
395
519
|
const selector = ${JSON.stringify(selector ?? null)};
|
|
396
520
|
const wanted = ${JSON.stringify(text ?? null)};
|
|
397
521
|
if (selector !== null && selector.length > 0) return document.querySelector(selector);
|
|
398
|
-
if (wanted
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
return null;
|
|
522
|
+
if (wanted === null || wanted.length === 0) return null;
|
|
523
|
+
const nodes = [...document.querySelectorAll('a,button,input,textarea,select,summary,label,li,td,th,span,div,p,[role=button],[role=link],[tabindex]')];
|
|
524
|
+
const read = (node) => (node.innerText || node.value || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
525
|
+
const actionable = (node) => node.matches('a[href],button,input,select,textarea,summary,[role=button],[role=link]');
|
|
526
|
+
const matches = nodes.filter((node) => { const value = read(node); return value === wanted || value.includes(wanted); });
|
|
527
|
+
if (matches.length === 0) return null;
|
|
528
|
+
/* Rank: exact text, then something clickable, then the smallest match —
|
|
529
|
+
so a link beats the container that merely contains its text. */
|
|
530
|
+
const score = (node) => {
|
|
531
|
+
const value = read(node);
|
|
532
|
+
return [value === wanted ? 0 : 1, actionable(node) ? 0 : 1, value.length];
|
|
533
|
+
};
|
|
534
|
+
matches.sort((left, right) => {
|
|
535
|
+
const a = score(left);
|
|
536
|
+
const b = score(right);
|
|
537
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
538
|
+
if (a[index] !== b[index]) return a[index] - b[index];
|
|
539
|
+
}
|
|
540
|
+
return 0;
|
|
541
|
+
});
|
|
542
|
+
return matches[0];
|
|
404
543
|
})()`;
|
|
405
544
|
}
|
|
406
545
|
|
|
@@ -430,6 +569,64 @@ window.__ModuleLoader__.load({
|
|
|
430
569
|
return frameUrl(view);
|
|
431
570
|
}
|
|
432
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Inspect the document a command is about to act on.
|
|
574
|
+
*
|
|
575
|
+
* A failed load leaves Chromium's error page in the webview, where every
|
|
576
|
+
* extraction legitimately returns nothing — which reads to a model as "the
|
|
577
|
+
* page is empty". This makes that state visible instead.
|
|
578
|
+
* @param view - carrier element.
|
|
579
|
+
* @returns `{ failed, href, title, empty }` for the loaded document.
|
|
580
|
+
*/
|
|
581
|
+
async function inspectPage(view) {
|
|
582
|
+
let state;
|
|
583
|
+
try {
|
|
584
|
+
state = await view.executeJavaScript('(() => ({ href: location.href, protocol: location.protocol, title: document.title, body: (document.body === null ? "" : document.body.innerText).slice(0, 60) }))()');
|
|
585
|
+
} catch {
|
|
586
|
+
return { failed: false, href: frameUrl(view) ?? '', title: frameTitle(view), empty: false };
|
|
587
|
+
}
|
|
588
|
+
const href = typeof state?.href === 'string' ? state.href : '';
|
|
589
|
+
const protocol = typeof state?.protocol === 'string' ? state.protocol : '';
|
|
590
|
+
return {
|
|
591
|
+
failed: href.startsWith('chrome-error:') || protocol === 'chrome-error:',
|
|
592
|
+
href,
|
|
593
|
+
title: typeof state?.title === 'string' ? state.title : '',
|
|
594
|
+
empty: typeof state?.body === 'string' && state.body.trim().length === 0
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Refuse to act on a document that never loaded.
|
|
600
|
+
* @param state - result of {@link inspectPage}.
|
|
601
|
+
* @param action - page action name, for the message.
|
|
602
|
+
*/
|
|
603
|
+
function requireLoaded(state, action) {
|
|
604
|
+
if (!state.failed) return;
|
|
605
|
+
throw new Error(`页面没有加载成功(内置浏览器里是 Chromium 错误页,通常是 DNS、网络或证书失败),"${action}" 没有内容可操作。可以先用 browser_control 的 reload 重试,或用 external 交给系统浏览器。`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Wait for a freshly opened tab to commit its first document, so the
|
|
610
|
+
* reported title and address belong to the page rather than to the guest's
|
|
611
|
+
* creation state (`about:blank#<lease>`, whose "title" is that string).
|
|
612
|
+
* @param frame - carrier element, when it is already mounted.
|
|
613
|
+
* @param timeoutMs - how long to wait for a committed document.
|
|
614
|
+
* @returns `{ title, url }` as observed; both empty/undefined until commit.
|
|
615
|
+
*/
|
|
616
|
+
async function waitForCommit(frame, timeoutMs = 4000) {
|
|
617
|
+
if (frame === undefined) return { title: '', url: undefined };
|
|
618
|
+
const deadline = Date.now() + timeoutMs;
|
|
619
|
+
for (;;) {
|
|
620
|
+
const url = frameUrl(frame);
|
|
621
|
+
const title = frameTitle(frame);
|
|
622
|
+
const committed = url !== undefined && title.length > 0 && !title.startsWith('about:');
|
|
623
|
+
if (committed || Date.now() >= deadline) {
|
|
624
|
+
return { title: title.startsWith('about:') ? '' : title, url };
|
|
625
|
+
}
|
|
626
|
+
await sleep(150);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
433
630
|
/**
|
|
434
631
|
* Run one `control` command through the sidebar controller and the carrier.
|
|
435
632
|
* @param args - command arguments.
|
|
@@ -455,12 +652,13 @@ window.__ModuleLoader__.load({
|
|
|
455
652
|
const entry = (await waitForNewEntry(known, screen)) ?? (target !== undefined ? target : defaultEntry());
|
|
456
653
|
if (entry !== undefined) await waitForFrame(entry.tabId, entry.sessionId, 3000);
|
|
457
654
|
const frame = entry === undefined ? undefined : frameFor(entry.tabId, entry.sessionId);
|
|
655
|
+
const committed = await waitForCommit(frame);
|
|
458
656
|
return {
|
|
459
657
|
action,
|
|
460
658
|
tab_id: entry?.handle,
|
|
461
659
|
session: entry?.sessionId,
|
|
462
|
-
url,
|
|
463
|
-
title:
|
|
660
|
+
url: committed.url ?? url,
|
|
661
|
+
title: committed.title || (entry === undefined ? undefined : recordFor(entry)?.title)
|
|
464
662
|
};
|
|
465
663
|
}
|
|
466
664
|
case 'navigate': {
|
|
@@ -469,11 +667,46 @@ window.__ModuleLoader__.load({
|
|
|
469
667
|
if (target === undefined) return await control({ action: 'open', url: args.url });
|
|
470
668
|
const frame = frameFor(target.tabId, target.sessionId);
|
|
471
669
|
if (isWebview(frame)) {
|
|
472
|
-
|
|
473
|
-
|
|
670
|
+
try {
|
|
671
|
+
await frame.loadURL(url);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
/* A failed load still leaves the tab at that address, showing
|
|
674
|
+
Chromium's error page; report it instead of failing the call. */
|
|
675
|
+
const failed = await inspectPage(frame);
|
|
676
|
+
return {
|
|
677
|
+
action,
|
|
678
|
+
tab_id: target.handle,
|
|
679
|
+
url: frameUrl(frame) ?? url,
|
|
680
|
+
note: failed.failed
|
|
681
|
+
? `页面加载失败(${String(error?.message ?? error)}):内置浏览器停在了 Chromium 错误页,通常是 DNS、网络或证书问题。可以换一个地址,或先用 external 交给系统浏览器。`
|
|
682
|
+
: `导航被中断:${String(error?.message ?? error)}`
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const committed = await waitForCommit(frame, 2000);
|
|
686
|
+
return { action, tab_id: target.handle, url: committed.url ?? url, title: committed.title };
|
|
474
687
|
}
|
|
688
|
+
/* A tab restored after a restart has no carrier until something loads
|
|
689
|
+
it, so a missing frame is a load request rather than an error. DSH's
|
|
690
|
+
replace rule closes the old tab and opens a new one, which means the
|
|
691
|
+
caller must be given the NEW id — the old one is already gone. */
|
|
692
|
+
const screen = onScreenSession();
|
|
693
|
+
if (target.sessionId !== screen) {
|
|
694
|
+
throw new Error(`标签页 ${target.handle} 属于另一个会话,无法从当前会话加载它。请先切换到那个会话(或在当前会话里用 open 打开同样的地址)。`);
|
|
695
|
+
}
|
|
696
|
+
const known = new Set(browserEntries().map((entry) => entry.handle));
|
|
475
697
|
service.openTab('browser', { params: { url }, replaceTab: target.tabId, revealIfOpened: false });
|
|
476
|
-
|
|
698
|
+
const fresh = (await waitForNewEntry(known, screen)) ?? target;
|
|
699
|
+
await waitForFrame(fresh.tabId, fresh.sessionId, 3000);
|
|
700
|
+
const loaded = await waitForCommit(frameFor(fresh.tabId, fresh.sessionId), 2500);
|
|
701
|
+
return {
|
|
702
|
+
action,
|
|
703
|
+
tab_id: fresh.handle,
|
|
704
|
+
url: loaded.url ?? url,
|
|
705
|
+
title: loaded.title,
|
|
706
|
+
note: fresh.handle === target.handle
|
|
707
|
+
? undefined
|
|
708
|
+
: `原标签 ${target.handle} 重启后尚未加载页面,已由新标签 ${fresh.handle} 加载它(DSH 的替换语义会新建标签)。请用 ${fresh.handle} 继续。`
|
|
709
|
+
};
|
|
477
710
|
}
|
|
478
711
|
case 'back':
|
|
479
712
|
case 'forward':
|
|
@@ -560,20 +793,35 @@ window.__ModuleLoader__.load({
|
|
|
560
793
|
const { frame, entry } = resolveTarget(args);
|
|
561
794
|
const view = requireWebview(frame);
|
|
562
795
|
const limit = Number.isFinite(args?.max_chars) ? Math.max(200, Math.min(200_000, Math.trunc(args.max_chars))) : 8000;
|
|
796
|
+
const state = await inspectPage(view);
|
|
563
797
|
const base = { action, tab_id: entry.handle, url: frameUrl(view), title: frameTitle(view) };
|
|
798
|
+
const pageNote = state.failed
|
|
799
|
+
? '页面是 Chromium 错误页(加载失败),内容为空是正常的:可用 browser_control 的 reload 重试。'
|
|
800
|
+
: undefined;
|
|
564
801
|
|
|
565
802
|
switch (action) {
|
|
566
803
|
case 'text': {
|
|
804
|
+
requireLoaded(state, action);
|
|
567
805
|
const text = await view.executeJavaScript('(() => { const body = document.body; return body === null ? "" : body.innerText; })()');
|
|
568
|
-
|
|
806
|
+
const rendered = typeof text === 'string' ? text : JSON.stringify(text);
|
|
807
|
+
return {
|
|
808
|
+
...base,
|
|
809
|
+
text: clamp(rendered, limit),
|
|
810
|
+
note: rendered.trim().length === 0
|
|
811
|
+
? '页面正文为空:可能是跳转还没完成(试试 wait)或内容由脚本延迟渲染(试试 eval 读具体节点)。'
|
|
812
|
+
: undefined
|
|
813
|
+
};
|
|
569
814
|
}
|
|
570
815
|
case 'links': {
|
|
816
|
+
requireLoaded(state, action);
|
|
571
817
|
const links = await view.executeJavaScript('(() => [...document.querySelectorAll("a[href]")].slice(0, 300).map((node) => ({ text: (node.innerText || node.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 160), href: node.href })).filter((link) => link.href.startsWith("http")))()');
|
|
572
|
-
const list = Array.isArray(links) ? links : [];
|
|
573
|
-
return { ...base, links: list
|
|
818
|
+
const list = (Array.isArray(links) ? links : []).slice(0, 120);
|
|
819
|
+
return { ...base, links: list, note: list.length === 0 ? '页面里没有 http(s) 链接。' : undefined };
|
|
574
820
|
}
|
|
575
821
|
case 'elements': {
|
|
822
|
+
requireLoaded(state, action);
|
|
576
823
|
const elements = await view.executeJavaScript(`(() => {
|
|
824
|
+
${pageHelpers()}
|
|
577
825
|
const path = (el) => {
|
|
578
826
|
const parts = [];
|
|
579
827
|
let node = el;
|
|
@@ -590,35 +838,265 @@ window.__ModuleLoader__.load({
|
|
|
590
838
|
}
|
|
591
839
|
return parts.join(' > ');
|
|
592
840
|
};
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
.
|
|
596
|
-
|
|
841
|
+
const nodes = [];
|
|
842
|
+
for (const doc of docs()) {
|
|
843
|
+
for (const el of doc.querySelectorAll('a[href],button,input,textarea,select,summary,[role=button],[role=link],[contenteditable=true]')) {
|
|
844
|
+
if (visible(el)) nodes.push(el);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return nodes.slice(0, 80).map((el) => {
|
|
848
|
+
const info = describe(el);
|
|
849
|
+
return {
|
|
850
|
+
tag: info.tag,
|
|
851
|
+
type: info.type,
|
|
852
|
+
selector: path(el),
|
|
853
|
+
text: (el.innerText || el.value || el.getAttribute('aria-label') || el.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
|
|
854
|
+
name: info.name,
|
|
855
|
+
placeholder: info.placeholder,
|
|
856
|
+
label: info.label || undefined,
|
|
857
|
+
autocomplete: info.autocomplete,
|
|
858
|
+
value: info.value || undefined,
|
|
859
|
+
checked: info.checked,
|
|
860
|
+
href: el.getAttribute('href') || undefined
|
|
861
|
+
};
|
|
862
|
+
});
|
|
597
863
|
})()`);
|
|
598
864
|
const list = Array.isArray(elements) ? elements : [];
|
|
599
|
-
return { ...base, elements: list,
|
|
865
|
+
return { ...base, elements: list, note: list.length === 0 ? '这个页面没有可交互元素(或它们都不可见)。' : undefined };
|
|
866
|
+
}
|
|
867
|
+
case 'fill': {
|
|
868
|
+
requireLoaded(state, action);
|
|
869
|
+
const specs = Array.isArray(args?.fields) ? args.fields : [];
|
|
870
|
+
if (specs.length === 0) throw new Error('fill 需要一个 fields 数组,例如 [{ label: "username", value: "alice" }, { label: "password", value: "…" }]。');
|
|
871
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
872
|
+
${pageHelpers()}
|
|
873
|
+
const specs = ${JSON.stringify(specs)};
|
|
874
|
+
const report = [];
|
|
875
|
+
let last = null;
|
|
876
|
+
for (const spec of specs) {
|
|
877
|
+
const el = findField(spec);
|
|
878
|
+
if (el === null) { report.push({ target: spec.selector || spec.label || spec.name || spec.placeholder || spec.type, ok: false, reason: 'not-found' }); continue; }
|
|
879
|
+
if (spec.value === undefined || spec.value === null) { report.push({ target: spec.selector || spec.label, ok: false, reason: 'no-value' }); continue; }
|
|
880
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
881
|
+
const wanted = spec.value === true || spec.value === 'true' || spec.value === 'on';
|
|
882
|
+
if (el.checked !== wanted) el.click();
|
|
883
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: el.type, checked: el.checked });
|
|
884
|
+
} else if (el.tagName === 'SELECT') {
|
|
885
|
+
el.value = String(spec.value);
|
|
886
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
887
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: 'select', value: String(el.value) });
|
|
888
|
+
} else {
|
|
889
|
+
el.focus();
|
|
890
|
+
setValue(el, String(spec.value));
|
|
891
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: el.type, filled: el.type === 'password' ? '••••' : String(el.value).slice(0, 40) });
|
|
892
|
+
}
|
|
893
|
+
last = el;
|
|
894
|
+
}
|
|
895
|
+
let submitted = false;
|
|
896
|
+
if (${args?.submit === true ? 'true' : 'false'} && last !== null) submitted = submitForm(last);
|
|
897
|
+
return { report, submitted };
|
|
898
|
+
})()`, true);
|
|
899
|
+
const filled = Array.isArray(outcome?.report) ? outcome.report.filter((entry) => entry.ok).length : 0;
|
|
900
|
+
const missing = Array.isArray(outcome?.report) ? outcome.report.filter((entry) => entry.ok !== true) : [];
|
|
901
|
+
const settled = outcome?.submitted === true ? await settle(view, base.url, 5000) : base.url;
|
|
902
|
+
return {
|
|
903
|
+
...base,
|
|
904
|
+
value: outcome,
|
|
905
|
+
url: settled,
|
|
906
|
+
title: frameTitle(view),
|
|
907
|
+
note: `${filled}/${specs.length} 个字段已填写${outcome?.submitted === true ? '并已提交表单' : ''}。${missing.length > 0 ? `未找到:${missing.map((entry) => entry.target).join('、')}(先用 elements 看可用字段)。` : ''} 密码不会出现在任何返回值里。`
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
case 'key': {
|
|
911
|
+
requireLoaded(state, action);
|
|
912
|
+
const key = typeof args?.key === 'string' && args.key.length > 0 ? args.key : 'Enter';
|
|
913
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
914
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
915
|
+
${pageHelpers()}
|
|
916
|
+
const el = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'document.activeElement'};
|
|
917
|
+
const target = el === null || el === undefined ? document.body : el;
|
|
918
|
+
if (target.focus !== undefined) target.focus();
|
|
919
|
+
const key = ${JSON.stringify(key)};
|
|
920
|
+
const code = key.length === 1 ? 'Key' + key.toUpperCase() : key;
|
|
921
|
+
const init = { key, code, keyCode: key === 'Enter' ? 13 : key === 'Escape' ? 27 : key === 'Tab' ? 9 : 0, which: 0, bubbles: true, cancelable: true };
|
|
922
|
+
target.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
923
|
+
target.dispatchEvent(new KeyboardEvent('keypress', init));
|
|
924
|
+
target.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
925
|
+
const submitted = key === 'Enter' && target.tagName !== 'TEXTAREA' ? submitForm(target) : false;
|
|
926
|
+
return { key, tag: target.tagName.toLowerCase(), submitted };
|
|
927
|
+
})()`, true);
|
|
928
|
+
const submittedKey = outcome?.submitted === true;
|
|
929
|
+
const settledKey = submittedKey ? await settle(view, base.url, 5000) : base.url;
|
|
930
|
+
return { ...base, value: outcome, url: settledKey, title: frameTitle(view), note: submittedKey ? `已按下 ${key} 并提交所在表单。` : `已按下 ${key}。` };
|
|
931
|
+
}
|
|
932
|
+
case 'login': {
|
|
933
|
+
requireLoaded(state, action);
|
|
934
|
+
const user = typeof args?.user === 'string' ? args.user : undefined;
|
|
935
|
+
const password = typeof args?.password === 'string' ? args.password : undefined;
|
|
936
|
+
if (user === undefined || password === undefined) throw new Error('login 需要 user 和 password 两个参数(密码只用于这一次填写,不会存入日志或返回)。');
|
|
937
|
+
const userSelector = typeof args?.user_selector === 'string' && args.user_selector.length > 0 ? args.user_selector : undefined;
|
|
938
|
+
const passwordSelector = typeof args?.password_selector === 'string' && args.password_selector.length > 0 ? args.password_selector : undefined;
|
|
939
|
+
const pre = await view.executeJavaScript(`(() => {
|
|
940
|
+
${pageHelpers()}
|
|
941
|
+
const list = fields().map(describe);
|
|
942
|
+
return { hasPassword: list.some((field) => field.type === 'password'), fields: list.slice(0, 12) };
|
|
943
|
+
})()`);
|
|
944
|
+
if (pre?.hasPassword !== true && passwordSelector === undefined) {
|
|
945
|
+
throw new Error('这个页面里没有可见的密码输入框(可能已经登录,或登录表单在跨域 iframe 里)。先用 elements 确认。');
|
|
946
|
+
}
|
|
947
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
948
|
+
${pageHelpers()}
|
|
949
|
+
const password = findField({ selector: ${JSON.stringify(passwordSelector ?? null)}, type: 'password' });
|
|
950
|
+
let account = findField({ selector: ${JSON.stringify(userSelector ?? null)}, type: 'username' });
|
|
951
|
+
if (account === null && password !== null) {
|
|
952
|
+
/* The account field is normally the last text input before the password. */
|
|
953
|
+
const before = fields().filter((el) => el.compareDocumentPosition(password) & Node.DOCUMENT_POSITION_FOLLOWING || el === password);
|
|
954
|
+
const textish = before.filter((el) => ['text', 'email', 'tel', 'number', ''].includes((el.getAttribute('type') || '').toLowerCase()) || el.tagName === 'INPUT');
|
|
955
|
+
account = textish.length > 0 ? textish[textish.length - 1] : null;
|
|
956
|
+
}
|
|
957
|
+
const report = [];
|
|
958
|
+
if (account !== null && account !== undefined) { account.focus(); setValue(account, ${JSON.stringify(user)}); report.push({ field: 'account', ok: true, name: describe(account).name ?? describe(account).label ?? '' }); }
|
|
959
|
+
else report.push({ field: 'account', ok: false });
|
|
960
|
+
if (password !== null && password !== undefined) { password.focus(); setValue(password, ${JSON.stringify(password)}); report.push({ field: 'password', ok: true }); }
|
|
961
|
+
else report.push({ field: 'password', ok: false });
|
|
962
|
+
const submitted = password !== null && password !== undefined ? submitForm(password) : false;
|
|
963
|
+
return { report, submitted };
|
|
964
|
+
})()`, true);
|
|
965
|
+
const accountOk = Array.isArray(outcome?.report) && outcome.report.some((entry) => entry.field === 'account' && entry.ok === true);
|
|
966
|
+
const passwordOk = Array.isArray(outcome?.report) && outcome.report.some((entry) => entry.field === 'password' && entry.ok === true);
|
|
967
|
+
const before = base.url;
|
|
968
|
+
const settledUrl = await settle(view, before, 8000);
|
|
969
|
+
const after = await view.executeJavaScript(`(() => {
|
|
970
|
+
${pageHelpers()}
|
|
971
|
+
const list = fields().map(describe);
|
|
972
|
+
const body = (document.body === null ? '' : document.body.innerText).slice(0, 4000);
|
|
973
|
+
return {
|
|
974
|
+
hasPassword: list.some((field) => field.type === 'password'),
|
|
975
|
+
signals: /log ?out|sign ?out|退出|注销|我的账号|my account|profile|dashboard/i.test(body) ? 'account-ui' : undefined,
|
|
976
|
+
error: /incorrect|invalid|wrong password|密码错误|验证码|错误|失败|captcha|验证/i.test(body) ? 'page-reports-a-problem' : undefined
|
|
977
|
+
};
|
|
978
|
+
})()`);
|
|
979
|
+
/* A login form inside an iframe navigates the frame, not the tab, so
|
|
980
|
+
both the frame's fields and the top document are re-checked. */
|
|
981
|
+
const frameLanded = await view.executeJavaScript(`(() => {
|
|
982
|
+
for (const doc of (() => {
|
|
983
|
+
const list = [document];
|
|
984
|
+
for (const frame of document.querySelectorAll('iframe')) { try { if (frame.contentDocument !== null) list.push(frame.contentDocument); } catch (error) { /* cross-origin */ } }
|
|
985
|
+
return list;
|
|
986
|
+
})()) {
|
|
987
|
+
const text = (doc.body === null ? '' : doc.body.innerText).slice(0, 2000);
|
|
988
|
+
if (/欢迎|welcome|dashboard|账号中心|已登录/i.test(text)) return { landed: true, url: doc.location.href, excerpt: text.replace(/\\s+/g, ' ').trim().slice(0, 120) };
|
|
989
|
+
}
|
|
990
|
+
return { landed: false };
|
|
991
|
+
})()`);
|
|
992
|
+
return {
|
|
993
|
+
...base,
|
|
994
|
+
url: settledUrl,
|
|
995
|
+
value: {
|
|
996
|
+
filled_account: accountOk,
|
|
997
|
+
filled_password: passwordOk,
|
|
998
|
+
submitted: outcome?.submitted === true,
|
|
999
|
+
password_field_gone: after?.hasPassword === false,
|
|
1000
|
+
landed: frameLanded?.landed === true ? frameLanded.url : undefined,
|
|
1001
|
+
signal: after?.signals,
|
|
1002
|
+
warning: after?.error
|
|
1003
|
+
},
|
|
1004
|
+
title: frameTitle(view),
|
|
1005
|
+
note: [
|
|
1006
|
+
accountOk && passwordOk ? '账号与密码已填写' : `字段查找:账号${accountOk ? '✓' : '✗'} 密码${passwordOk ? '✓' : '✗'}(可用 *_selector 明确指定)`,
|
|
1007
|
+
outcome?.submitted === true ? '已提交' : '未提交(请检查提交按钮,或用 click 点它)',
|
|
1008
|
+
frameLanded?.landed === true ? `已看到登录后的页面内容:${frameLanded.excerpt}` : undefined,
|
|
1009
|
+
after?.hasPassword === false ? '登录框已消失,通常表示登录成功' : undefined,
|
|
1010
|
+
after?.signals === 'account-ui' ? '页面上出现了账号相关文字,可进一步确认' : undefined,
|
|
1011
|
+
after?.error === 'page-reports-a-problem' ? '页面文字里出现了错误/验证码类提示,可能需要人工处理' : undefined
|
|
1012
|
+
].filter((part) => typeof part === 'string').join(';')
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
case 'images': {
|
|
1016
|
+
requireLoaded(state, action);
|
|
1017
|
+
const images = await view.executeJavaScript(`(() => {
|
|
1018
|
+
const out = [];
|
|
1019
|
+
for (const el of document.querySelectorAll('img,picture source,svg image,canvas')) {
|
|
1020
|
+
const rect = el.getBoundingClientRect();
|
|
1021
|
+
if (!(rect.width > 1 && rect.height > 1 && rect.bottom > -50 && rect.top < window.innerHeight + 50)) continue;
|
|
1022
|
+
const source = el.currentSrc || el.src || '';
|
|
1023
|
+
out.push({
|
|
1024
|
+
index: out.length + 1,
|
|
1025
|
+
tag: el.tagName.toLowerCase(),
|
|
1026
|
+
alt: el.getAttribute('alt') || undefined,
|
|
1027
|
+
width: Math.round(rect.width),
|
|
1028
|
+
height: Math.round(rect.height),
|
|
1029
|
+
natural_width: el.naturalWidth || undefined,
|
|
1030
|
+
natural_height: el.naturalHeight || undefined,
|
|
1031
|
+
src: String(source).slice(0, 200)
|
|
1032
|
+
});
|
|
1033
|
+
if (out.length >= 40) break;
|
|
1034
|
+
}
|
|
1035
|
+
return out;
|
|
1036
|
+
})()`);
|
|
1037
|
+
const list = Array.isArray(images) ? images : [];
|
|
1038
|
+
return {
|
|
1039
|
+
...base,
|
|
1040
|
+
images: list,
|
|
1041
|
+
note: list.length === 0
|
|
1042
|
+
? '当前视口里没有可见图片(可以先 scroll 或 wait 再试)。'
|
|
1043
|
+
: '用 browser_page { action: "image", index: N } 就能直接看到其中一张。'
|
|
1044
|
+
};
|
|
600
1045
|
}
|
|
601
1046
|
case 'eval': {
|
|
602
1047
|
if (typeof args?.code !== 'string' || args.code.trim().length === 0) throw new Error('eval 需要一个 code 参数。');
|
|
603
1048
|
const value = await view.executeJavaScript(args.code, true);
|
|
604
|
-
return { ...base, value: clampValue(value, limit) };
|
|
1049
|
+
return { ...base, value: clampValue(value, limit), note: pageNote };
|
|
605
1050
|
}
|
|
606
1051
|
case 'click': {
|
|
607
1052
|
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
608
1053
|
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
609
1054
|
if (selector === undefined && wanted === undefined) throw new Error('click 需要 selector 或 text 参数。');
|
|
1055
|
+
requireLoaded(state, action);
|
|
1056
|
+
const before = new Set(browserEntries().map((candidate) => candidate.handle));
|
|
610
1057
|
const outcome = await view.executeJavaScript(`(() => {
|
|
611
1058
|
const el = ${matchExpression(selector, wanted)};
|
|
612
1059
|
if (el === null) return { ok: false, reason: 'not-found' };
|
|
613
1060
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
614
1061
|
el.click();
|
|
615
|
-
return {
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
tag: el.tagName.toLowerCase(),
|
|
1065
|
+
text: (el.innerText || el.value || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
|
|
1066
|
+
target: el.getAttribute('target') || undefined,
|
|
1067
|
+
actionable: el.matches('a[href],button,input,select,textarea,summary,[role=button],[role=link]')
|
|
1068
|
+
};
|
|
616
1069
|
})()`, true);
|
|
617
1070
|
if (outcome?.ok !== true) throw new Error(`没有找到可点击的目标(${selector ?? wanted})。可先用 browser_page 的 elements 查看可用元素。`);
|
|
618
|
-
|
|
1071
|
+
await sleep(700);
|
|
1072
|
+
const opened = browserEntries().find((candidate) => !before.has(candidate.handle));
|
|
1073
|
+
if (opened !== undefined) {
|
|
1074
|
+
const openedFrame = (await waitForFrame(opened.tabId, opened.sessionId, 3000)) ?? undefined;
|
|
1075
|
+
const committed = await waitForCommit(openedFrame, 2500);
|
|
1076
|
+
return {
|
|
1077
|
+
...base,
|
|
1078
|
+
value: outcome,
|
|
1079
|
+
url: await settle(view, base.url, 2000),
|
|
1080
|
+
opened_tab: opened.handle,
|
|
1081
|
+
opened_url: committed.url,
|
|
1082
|
+
opened_title: committed.title,
|
|
1083
|
+
note: `该链接在新标签页打开:DSH 把 popup 请求变成了新的侧栏标签 ${opened.handle}。请用它继续操作,原标签 ${entry.handle} 仍停在当前页面。`
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const settledUrl = await settle(view, base.url, 3000);
|
|
1087
|
+
const stalled = outcome.actionable === false && settledUrl === base.url;
|
|
1088
|
+
return {
|
|
1089
|
+
...base,
|
|
1090
|
+
value: outcome,
|
|
1091
|
+
url: settledUrl,
|
|
1092
|
+
note: stalled
|
|
1093
|
+
? `点击的元素不是链接或按钮(匹配到的是 <${outcome.tag}> 这样的外层容器),页面没有任何变化。请改用 browser_page 的 elements 取一个精确 selector,再点那个链接或按钮。`
|
|
1094
|
+
: '已派发点击。'
|
|
1095
|
+
};
|
|
619
1096
|
}
|
|
620
1097
|
case 'type': {
|
|
621
1098
|
if (typeof args?.text !== 'string') throw new Error('type 需要一个 text 参数。');
|
|
1099
|
+
requireLoaded(state, action);
|
|
622
1100
|
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
623
1101
|
const submit = args?.submit === true;
|
|
624
1102
|
const outcome = await view.executeJavaScript(`(() => {
|
|
@@ -641,6 +1119,7 @@ window.__ModuleLoader__.load({
|
|
|
641
1119
|
return { ...base, value: { tag: outcome.tag, value: String(outcome.value ?? '').slice(0, 200) }, note: submit ? '已填写并提交。' : '已填写。' };
|
|
642
1120
|
}
|
|
643
1121
|
case 'scroll': {
|
|
1122
|
+
requireLoaded(state, action);
|
|
644
1123
|
const pixels = Number.isFinite(args?.ms) ? Math.trunc(args.ms) : 0;
|
|
645
1124
|
await view.executeJavaScript(`(() => { window.scrollBy({ top: ${pixels} === 0 ? Math.round(window.innerHeight * 0.9) : ${pixels}, behavior: 'smooth' }); return true; })()`);
|
|
646
1125
|
await sleep(450);
|
|
@@ -648,19 +1127,24 @@ window.__ModuleLoader__.load({
|
|
|
648
1127
|
return { ...base, value: position };
|
|
649
1128
|
}
|
|
650
1129
|
case 'wait': {
|
|
1130
|
+
requireLoaded(state, action);
|
|
651
1131
|
const timeout = Number.isFinite(args?.ms) ? Math.max(200, Math.min(60_000, Math.trunc(args.ms))) : 10_000;
|
|
652
1132
|
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
653
1133
|
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
1134
|
+
const urlWanted = typeof args?.url_contains === 'string' && args.url_contains.length > 0 ? args.url_contains : undefined;
|
|
654
1135
|
const condition = selector !== undefined || wanted !== undefined
|
|
655
1136
|
? `(() => { const el = ${matchExpression(selector, wanted)}; return el !== null; })()`
|
|
656
1137
|
: '(() => document.readyState === "complete")()';
|
|
657
1138
|
const deadline = Date.now() + timeout;
|
|
658
1139
|
while (Date.now() < deadline) {
|
|
659
|
-
|
|
1140
|
+
if (urlWanted !== undefined && (frameUrl(view) ?? '').includes(urlWanted)) {
|
|
1141
|
+
return { ...base, url: frameUrl(view), note: `网址已包含 ${urlWanted}。` };
|
|
1142
|
+
}
|
|
1143
|
+
const ready = urlWanted === undefined ? await view.executeJavaScript(condition) : false;
|
|
660
1144
|
if (ready === true) return { ...base, note: '条件已满足。' };
|
|
661
1145
|
await sleep(250);
|
|
662
1146
|
}
|
|
663
|
-
throw new Error(`等待超时(${timeout}ms):${selector ?? wanted ?? '页面加载'}
|
|
1147
|
+
throw new Error(`等待超时(${timeout}ms):${urlWanted !== undefined ? `网址未包含 ${urlWanted}` : `${selector ?? wanted ?? '页面加载'} 未出现`}。`);
|
|
664
1148
|
}
|
|
665
1149
|
case 'screenshot':
|
|
666
1150
|
throw new Error('本插件不提供截图:webview.capturePage() 会让 Electron 渲染进程原生崩溃(已实测),因此该能力被刻意移除。需要页面图像时,用 browser_control 的 external 在系统浏览器中打开。');
|
|
@@ -682,11 +1166,109 @@ window.__ModuleLoader__.load({
|
|
|
682
1166
|
return await control(command.args ?? {});
|
|
683
1167
|
case 'page':
|
|
684
1168
|
return await page(command.args ?? {});
|
|
1169
|
+
case 'geometry':
|
|
1170
|
+
return geometry(command.args ?? {});
|
|
1171
|
+
case 'image':
|
|
1172
|
+
return await image(command.args ?? {});
|
|
685
1173
|
default:
|
|
686
1174
|
throw new Error(`未知的内置浏览器操作:${command.op}`);
|
|
687
1175
|
}
|
|
688
1176
|
}
|
|
689
1177
|
|
|
1178
|
+
/**
|
|
1179
|
+
* Where the Browser pane sits inside the DSH window.
|
|
1180
|
+
*
|
|
1181
|
+
* The Host half captures the window itself (there is no safe in-renderer
|
|
1182
|
+
* capture API), so it needs the pane's viewport rectangle plus the frame
|
|
1183
|
+
* offsets that separate the viewport from the window rectangle.
|
|
1184
|
+
* @param args - `{ tab_id? }`.
|
|
1185
|
+
* @returns geometry for the Host's crop maths.
|
|
1186
|
+
*/
|
|
1187
|
+
function geometry(args) {
|
|
1188
|
+
const { frame, entry } = resolveTarget(args);
|
|
1189
|
+
const rect = frame.getBoundingClientRect();
|
|
1190
|
+
return {
|
|
1191
|
+
tab_id: entry.handle,
|
|
1192
|
+
url: frameUrl(frame),
|
|
1193
|
+
title: frameTitle(frame),
|
|
1194
|
+
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
|
1195
|
+
devicePixelRatio: window.devicePixelRatio || 1,
|
|
1196
|
+
outer: {
|
|
1197
|
+
width: window.outerWidth,
|
|
1198
|
+
height: window.outerHeight,
|
|
1199
|
+
innerWidth: window.innerWidth,
|
|
1200
|
+
innerHeight: window.innerHeight
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Hand the Host one picture from the page so it can attach it to a result.
|
|
1207
|
+
*
|
|
1208
|
+
* Bytes travel from the Host, not from here: the page's own `fetch` is bound
|
|
1209
|
+
* by CORS, while the Host's Node fetch is not. Only `data:` and `blob:`
|
|
1210
|
+
* sources — which the Host cannot read — are decoded in the page.
|
|
1211
|
+
* @param args - `{ tab_id?, index?, selector?, src? }`.
|
|
1212
|
+
* @returns `{ url }` for the Host to fetch, or `{ inline, media_type }`.
|
|
1213
|
+
*/
|
|
1214
|
+
async function image(args) {
|
|
1215
|
+
const { frame, entry } = resolveTarget(args);
|
|
1216
|
+
requireWebview(frame);
|
|
1217
|
+
const index = Number.isFinite(args?.index) ? Math.trunc(args.index) : undefined;
|
|
1218
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
1219
|
+
const wanted = typeof args?.src === 'string' && args.src.length > 0 ? args.src : undefined;
|
|
1220
|
+
if (index === undefined && selector === undefined && wanted === undefined) {
|
|
1221
|
+
throw new Error('image 需要 index(来自 images)、selector 或 src 之一。');
|
|
1222
|
+
}
|
|
1223
|
+
const found = await frame.executeJavaScript(`(() => {
|
|
1224
|
+
/* Same ordering as the images action: viewport-visible pictures only. */
|
|
1225
|
+
const all = [...document.querySelectorAll('img,picture source,svg image,canvas')].filter((el) => {
|
|
1226
|
+
const rect = el.getBoundingClientRect();
|
|
1227
|
+
return rect.width > 1 && rect.height > 1 && rect.bottom > -50 && rect.top < window.innerHeight + 50;
|
|
1228
|
+
});
|
|
1229
|
+
const pick = ${index !== undefined ? `all[${index - 1}] ?? null` : 'null'};
|
|
1230
|
+
const bySelector = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'null'};
|
|
1231
|
+
const el = pick ?? bySelector ?? null;
|
|
1232
|
+
if (el === null) return { ok: false };
|
|
1233
|
+
const toBlobSource = ${wanted !== undefined ? JSON.stringify(wanted) : 'null'};
|
|
1234
|
+
const source = toBlobSource ?? el.currentSrc ?? el.src ?? (el.tagName === 'CANVAS' ? el.toDataURL('image/png') : null);
|
|
1235
|
+
if (source === null || source === undefined || String(source).length === 0) return { ok: false, reason: 'no-source' };
|
|
1236
|
+
const rect = el.getBoundingClientRect();
|
|
1237
|
+
return {
|
|
1238
|
+
ok: true,
|
|
1239
|
+
src: String(source),
|
|
1240
|
+
alt: el.getAttribute('alt') || undefined,
|
|
1241
|
+
width: Math.round(rect.width),
|
|
1242
|
+
height: Math.round(rect.height),
|
|
1243
|
+
natural_width: el.naturalWidth || undefined,
|
|
1244
|
+
natural_height: el.naturalHeight || undefined,
|
|
1245
|
+
tag: el.tagName.toLowerCase()
|
|
1246
|
+
};
|
|
1247
|
+
})()`);
|
|
1248
|
+
if (found?.ok !== true) throw new Error('没有找到这张图片(先用 images 看列表,或用 selector 指定)。');
|
|
1249
|
+
const src = String(found.src);
|
|
1250
|
+
const base = { action: 'image', tab_id: entry.handle, url: frameUrl(frame), title: frameTitle(frame), source: src.slice(0, 300), alt: found.alt, width: found.width, height: found.height };
|
|
1251
|
+
const inline = /^data:([^;,]+)?(;base64)?,/u.exec(src);
|
|
1252
|
+
if (inline !== null) {
|
|
1253
|
+
const comma = src.indexOf(',');
|
|
1254
|
+
const bytes = inline[2] === ';base64' ? src.slice(comma + 1) : btoa(unescape(encodeURIComponent(decodeURIComponent(src.slice(comma + 1)))));
|
|
1255
|
+
return { ...base, inline: bytes, media_type: inline[1] ?? 'image/png' };
|
|
1256
|
+
}
|
|
1257
|
+
if (src.startsWith('blob:')) {
|
|
1258
|
+
const drawn = await frame.executeJavaScript(`(async () => {
|
|
1259
|
+
const response = await fetch(${JSON.stringify(src)});
|
|
1260
|
+
const buffer = await response.arrayBuffer();
|
|
1261
|
+
const bytes = new Uint8Array(buffer);
|
|
1262
|
+
let binary = '';
|
|
1263
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode.apply(null, bytes.subarray(index, index + 0x8000));
|
|
1264
|
+
return { base64: btoa(binary), type: response.headers.get('content-type') || 'image/png' };
|
|
1265
|
+
})()`);
|
|
1266
|
+
if (typeof drawn?.base64 === 'string') return { ...base, image_url: src, inline: drawn.base64, media_type: drawn.type };
|
|
1267
|
+
throw new Error('无法读取 blob: 图片。');
|
|
1268
|
+
}
|
|
1269
|
+
return { ...base, image_url: src };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
690
1272
|
/**
|
|
691
1273
|
* Report one settled command back to the Host half.
|
|
692
1274
|
* @param id - command id.
|