xshat-lite 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1384 @@
1
+ import { launchPersistentContext } from 'cloakbrowser';
2
+ import { resolve } from 'node:path';
3
+ import config from '../utils/config.mjs';
4
+
5
+ function profileDirForChannel(userDataDir, channel) {
6
+ return resolve(userDataDir, channel);
7
+ }
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function clipText(text, maxChars = 400) {
14
+ const normalized = String(text ?? '')
15
+ .replace(/\s+/g, ' ')
16
+ .trim();
17
+
18
+ if (normalized.length <= maxChars) {
19
+ return normalized;
20
+ }
21
+
22
+ return `${normalized.slice(0, Math.max(0, maxChars - 1))}…`;
23
+ }
24
+
25
+ function pushLimited(list, item, maxEntries = 30) {
26
+ list.push(item);
27
+ if (list.length > maxEntries) {
28
+ list.splice(0, list.length - maxEntries);
29
+ }
30
+ }
31
+
32
+ function createObservationStore(maxEntries = 30) {
33
+ return {
34
+ maxEntries,
35
+ updatedAt: null,
36
+ requests: [],
37
+ responses: [],
38
+ console: [],
39
+ pageErrors: [],
40
+ websockets: []
41
+ };
42
+ }
43
+
44
+ function pickHeader(headers = {}, name = '') {
45
+ const lowerName = String(name).toLowerCase();
46
+ for (const [key, value] of Object.entries(headers || {})) {
47
+ if (String(key).toLowerCase() === lowerName) {
48
+ return value;
49
+ }
50
+ }
51
+ return '';
52
+ }
53
+
54
+ function isTextLikeContentType(contentType = '') {
55
+ const value = String(contentType).toLowerCase();
56
+ return (
57
+ value.includes('json') ||
58
+ value.includes('text/') ||
59
+ value.includes('javascript') ||
60
+ value.includes('xml') ||
61
+ value.includes('html')
62
+ );
63
+ }
64
+
65
+ function localStorageEntriesFromState(state = {}) {
66
+ return (state.origins || [])
67
+ .filter((origin) => origin?.origin && Array.isArray(origin.localStorage))
68
+ .map((origin) => ({
69
+ origin: origin.origin,
70
+ localStorage: origin.localStorage
71
+ }));
72
+ }
73
+
74
+ function splitSelectorCandidates(selector) {
75
+ const source = String(selector ?? '').trim();
76
+ if (!source) {
77
+ return [];
78
+ }
79
+
80
+ const parts = [];
81
+ let current = '';
82
+ let quote = '';
83
+ let depth = 0;
84
+
85
+ for (let index = 0; index < source.length; index++) {
86
+ const char = source[index];
87
+ const prev = source[index - 1];
88
+
89
+ if (quote) {
90
+ current += char;
91
+ if (char === quote && prev !== '\\') {
92
+ quote = '';
93
+ }
94
+ continue;
95
+ }
96
+
97
+ if (char === '"' || char === '\'') {
98
+ quote = char;
99
+ current += char;
100
+ continue;
101
+ }
102
+
103
+ if (char === '(' || char === '[') {
104
+ depth++;
105
+ current += char;
106
+ continue;
107
+ }
108
+
109
+ if ((char === ')' || char === ']') && depth > 0) {
110
+ depth--;
111
+ current += char;
112
+ continue;
113
+ }
114
+
115
+ if (char === ',' && depth === 0) {
116
+ if (current.trim()) {
117
+ parts.push(current.trim());
118
+ }
119
+ current = '';
120
+ continue;
121
+ }
122
+
123
+ current += char;
124
+ }
125
+
126
+ if (current.trim()) {
127
+ parts.push(current.trim());
128
+ }
129
+
130
+ return parts;
131
+ }
132
+
133
+ const genericEditableFallbacks = [
134
+ { selector: 'textarea:not([disabled]):not([readonly])', weight: 120 },
135
+ { selector: 'textarea[placeholder]', weight: 140 },
136
+ { selector: 'input[type="text"]:not([disabled]):not([readonly])', weight: 105 },
137
+ { selector: 'input[type="search"]:not([disabled]):not([readonly])', weight: 100 },
138
+ { selector: 'input:not([type]):not([disabled]):not([readonly])', weight: 95 },
139
+ { selector: '[contenteditable="true"]', weight: 135 },
140
+ { selector: '[contenteditable="plaintext-only"]', weight: 135 },
141
+ { selector: '[role="textbox"]', weight: 90 },
142
+ { selector: 'textarea', weight: 60 },
143
+ { selector: 'input', weight: 30 }
144
+ ];
145
+
146
+ const chatLikeInputPattern = /message|prompt|chat|ask|question|input|search|write|tell|输入|消息|提问|发送|对话|问题|搜索|写点/i;
147
+
148
+ function isRecoverableActionError(error) {
149
+ const message = String(error?.message || error || '').toLowerCase();
150
+ return [
151
+ 'element is not visible',
152
+ 'element is outside of the viewport',
153
+ 'element is not attached to the dom',
154
+ 'element is detached from document',
155
+ 'another element',
156
+ 'intercepts pointer events',
157
+ 'not stable',
158
+ 'timeout',
159
+ 'strict mode violation'
160
+ ].some((pattern) => message.includes(pattern));
161
+ }
162
+
163
+ async function pickBestLocator(page, selector, {
164
+ requireEditable = false
165
+ } = {}) {
166
+ const locator = page.locator(selector);
167
+ const count = await locator.count().catch(() => 0);
168
+
169
+ if (count <= 0) {
170
+ return null;
171
+ }
172
+
173
+ let fallback = null;
174
+
175
+ for (let index = 0; index < count; index++) {
176
+ const candidate = locator.nth(index);
177
+
178
+ if (!fallback) {
179
+ fallback = candidate;
180
+ }
181
+
182
+ try {
183
+ const visible = typeof candidate.isVisible === 'function'
184
+ ? await candidate.isVisible()
185
+ : true;
186
+
187
+ if (!visible) {
188
+ continue;
189
+ }
190
+
191
+ if (!requireEditable) {
192
+ return candidate;
193
+ }
194
+
195
+ const editable = await candidate.evaluate((node) => {
196
+ if (!(node instanceof HTMLElement)) {
197
+ return false;
198
+ }
199
+
200
+ if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
201
+ return !node.disabled && !node.readOnly;
202
+ }
203
+
204
+ return node.isContentEditable;
205
+ }).catch(() => false);
206
+
207
+ if (editable) {
208
+ return candidate;
209
+ }
210
+ } catch {
211
+ // keep trying other matches
212
+ }
213
+ }
214
+
215
+ return fallback;
216
+ }
217
+
218
+ function scoreEditableCandidate(meta = {}, baseWeight = 0) {
219
+ let score = Number(baseWeight) || 0;
220
+ const tag = String(meta.tag || '').toLowerCase();
221
+ const type = String(meta.type || '').toLowerCase();
222
+ const combinedHint = [
223
+ meta.placeholder,
224
+ meta.ariaLabel,
225
+ meta.name,
226
+ meta.id,
227
+ ...(Array.isArray(meta.classes) ? meta.classes : [])
228
+ ].join(' ');
229
+
230
+ if (meta.visible) {
231
+ score += 220;
232
+ } else {
233
+ score -= 220;
234
+ }
235
+
236
+ if (meta.editable) {
237
+ score += 180;
238
+ } else {
239
+ score -= 280;
240
+ }
241
+
242
+ if (tag === 'textarea') {
243
+ score += 80;
244
+ }
245
+
246
+ if (tag === 'input') {
247
+ if (!type || ['text', 'search', 'email', 'url', 'tel'].includes(type)) {
248
+ score += 60;
249
+ } else {
250
+ score -= 120;
251
+ }
252
+ }
253
+
254
+ if (meta.contentEditable) {
255
+ score += 75;
256
+ }
257
+
258
+ if (meta.role === 'textbox') {
259
+ score += 40;
260
+ }
261
+
262
+ if (chatLikeInputPattern.test(combinedHint)) {
263
+ score += 110;
264
+ }
265
+
266
+ if ((meta.textLength || 0) > 0) {
267
+ score += Math.min(30, Math.floor((meta.textLength || 0) / 8));
268
+ }
269
+
270
+ const area = Number(meta.area) || 0;
271
+ if (area > 0) {
272
+ score += Math.min(45, Math.floor(area / 1200));
273
+ }
274
+
275
+ return score;
276
+ }
277
+
278
+ async function inspectEditableCandidate(locator) {
279
+ return locator.evaluate((node) => {
280
+ if (!(node instanceof HTMLElement)) {
281
+ return null;
282
+ }
283
+
284
+ const style = window.getComputedStyle(node);
285
+ const rect = node.getBoundingClientRect();
286
+ const tag = node.tagName.toLowerCase();
287
+ const input = node instanceof HTMLInputElement ? node : null;
288
+ const textarea = node instanceof HTMLTextAreaElement ? node : null;
289
+ const editable = Boolean(
290
+ (input && !input.disabled && !input.readOnly) ||
291
+ (textarea && !textarea.disabled && !textarea.readOnly) ||
292
+ node.isContentEditable
293
+ );
294
+
295
+ return {
296
+ tag,
297
+ type: input?.type || '',
298
+ role: node.getAttribute('role') || '',
299
+ placeholder: input?.placeholder || textarea?.placeholder || node.getAttribute('placeholder') || '',
300
+ ariaLabel: node.getAttribute('aria-label') || '',
301
+ name: node.getAttribute('name') || '',
302
+ id: node.id || '',
303
+ classes: typeof node.className === 'string'
304
+ ? node.className.split(/\s+/).filter(Boolean).slice(0, 8)
305
+ : [],
306
+ contentEditable: node.isContentEditable,
307
+ editable,
308
+ visible: (
309
+ rect.width > 0 &&
310
+ rect.height > 0 &&
311
+ style.visibility !== 'hidden' &&
312
+ style.display !== 'none'
313
+ ),
314
+ textLength: String(node.textContent || '').trim().length,
315
+ area: Math.round(rect.width * rect.height)
316
+ };
317
+ }).catch(() => null);
318
+ }
319
+
320
+ async function findEditableFallbackLocator(page) {
321
+ const best = {
322
+ locator: null,
323
+ score: Number.NEGATIVE_INFINITY
324
+ };
325
+
326
+ for (const entry of genericEditableFallbacks) {
327
+ const locator = page.locator(entry.selector);
328
+ const count = await locator.count().catch(() => 0);
329
+
330
+ for (let index = 0; index < count; index++) {
331
+ const candidate = locator.nth(index);
332
+ const meta = await inspectEditableCandidate(candidate);
333
+ if (!meta) {
334
+ continue;
335
+ }
336
+
337
+ const score = scoreEditableCandidate(meta, entry.weight);
338
+ if (score > best.score) {
339
+ best.locator = candidate;
340
+ best.score = score;
341
+ }
342
+ }
343
+ }
344
+
345
+ return best.locator;
346
+ }
347
+
348
+ export class BrowserManager {
349
+ constructor({
350
+ launcher = launchPersistentContext,
351
+ browserConfig = config.browser
352
+ } = {}) {
353
+ this.launcher = launcher;
354
+ this.browserConfig = { ...browserConfig };
355
+ this.channels = new Map();
356
+ this.chatManager = null;
357
+ }
358
+
359
+ setChatManager(chatManager = null) {
360
+ this.chatManager = chatManager || null;
361
+ }
362
+
363
+ getChatManager() {
364
+ return this.chatManager;
365
+ }
366
+
367
+ _getChannel(name) {
368
+ if (!this.channels.has(name)) {
369
+ this.channels.set(name, {
370
+ browser: null,
371
+ page: null,
372
+ config: { ...this.browserConfig },
373
+ pendingNavigation: null,
374
+ lastNavigationError: null,
375
+ observation: createObservationStore(
376
+ Number(this.browserConfig.observationLimit) || 30
377
+ )
378
+ });
379
+ }
380
+
381
+ return this.channels.get(name);
382
+ }
383
+
384
+ _markChannelClosed(name = 'chat') {
385
+ const channel = this._getChannel(name);
386
+ channel.browser = null;
387
+ channel.page = null;
388
+ channel.pendingNavigation = null;
389
+ channel.lastNavigationError = null;
390
+ }
391
+
392
+ _isClosedError(error) {
393
+ const message = String(error?.message || error || '').toLowerCase();
394
+ return (
395
+ message.includes('has been closed') ||
396
+ message.includes('target page, context or browser has been closed') ||
397
+ message.includes('browser has been closed') ||
398
+ message.includes('context has been closed') ||
399
+ message.includes('page has been closed')
400
+ );
401
+ }
402
+
403
+ async probeChannel(name = 'chat') {
404
+ const channel = this._getChannel(name);
405
+ if (!channel.browser || !channel.page) {
406
+ this._markChannelClosed(name);
407
+ return false;
408
+ }
409
+
410
+ try {
411
+ if (typeof channel.page.url === 'function') {
412
+ channel.page.url();
413
+ }
414
+
415
+ if (typeof channel.browser.pages === 'function') {
416
+ await channel.browser.pages();
417
+ }
418
+
419
+ return true;
420
+ } catch (error) {
421
+ if (this._isClosedError(error)) {
422
+ this._markChannelClosed(name);
423
+ return false;
424
+ }
425
+ throw error;
426
+ }
427
+ }
428
+
429
+ async launchChannel(name = 'chat', overrides = {}) {
430
+ const channel = this._getChannel(name);
431
+ const launchOptions = {
432
+ headless: overrides.headless ?? channel.config.headless,
433
+ humanize: overrides.humanize ?? channel.config.humanize,
434
+ userDataDir:
435
+ overrides.userDataDir ??
436
+ profileDirForChannel(channel.config.userDataDir, name)
437
+ };
438
+
439
+ channel.config = {
440
+ ...channel.config,
441
+ ...overrides,
442
+ ...launchOptions
443
+ };
444
+
445
+ channel.browser = await this.launcher({
446
+ headless: launchOptions.headless,
447
+ humanize: launchOptions.humanize,
448
+ userDataDir: launchOptions.userDataDir
449
+ });
450
+
451
+ channel.observation = createObservationStore(
452
+ Number(channel.config.observationLimit) ||
453
+ Number(this.browserConfig.observationLimit) ||
454
+ 30
455
+ );
456
+ channel.page = await channel.browser.newPage();
457
+ this.annotatePage(channel.page, name);
458
+ this.attachChannelObservers(name);
459
+
460
+ if (this.browserConfig.shareAuthState && name !== 'chat' && this.isChannelReady('chat')) {
461
+ await this.syncStorageState('chat', name);
462
+ }
463
+
464
+ return channel.page;
465
+ }
466
+
467
+ async init(overrides = {}) {
468
+ return this.initChannel('chat', overrides);
469
+ }
470
+
471
+ async initChannel(name = 'chat', overrides = {}) {
472
+ const channel = this._getChannel(name);
473
+ if (await this.probeChannel(name)) {
474
+ return channel.page;
475
+ }
476
+
477
+ return this.launchChannel(name, overrides);
478
+ }
479
+
480
+ async goto(url) {
481
+ return this.gotoChannel('chat', url);
482
+ }
483
+
484
+ async gotoChannel(name = 'chat', url) {
485
+ return this.navigateChannel(name, url, { background: false });
486
+ }
487
+
488
+ async navigateChannel(name = 'chat', url, {
489
+ background = false,
490
+ waitUntil = 'domcontentloaded',
491
+ timeout
492
+ } = {}) {
493
+ const channel = this._getChannel(name);
494
+ if (!await this.probeChannel(name)) {
495
+ throw new Error(`浏览器页面未初始化: ${name}`);
496
+ }
497
+
498
+ channel.lastNavigationError = null;
499
+ const navigation = channel.page.goto(url, {
500
+ waitUntil,
501
+ timeout: timeout ?? channel.config.gotoTimeout
502
+ });
503
+
504
+ if (!background) {
505
+ await navigation;
506
+ return channel.page;
507
+ }
508
+
509
+ channel.pendingNavigation = navigation
510
+ .catch((error) => {
511
+ channel.lastNavigationError = error;
512
+ return null;
513
+ })
514
+ .finally(() => {
515
+ channel.pendingNavigation = null;
516
+ });
517
+
518
+ return channel.page;
519
+ }
520
+
521
+ getCurrentUrl() {
522
+ return this.getCurrentUrlFor('chat');
523
+ }
524
+
525
+ getCurrentUrlFor(name = 'chat') {
526
+ const channel = this._getChannel(name);
527
+ return channel.page ? channel.page.url() : null;
528
+ }
529
+
530
+ async relaunch({ url, ...overrides } = {}) {
531
+ return this.relaunchChannel('chat', { url, ...overrides });
532
+ }
533
+
534
+ async relaunchChannel(name = 'chat', { url, ...overrides } = {}) {
535
+ const targetUrl = url ?? this.getCurrentUrlFor(name);
536
+ await this.closeChannel(name);
537
+ await this.launchChannel(name, overrides);
538
+
539
+ if (targetUrl) {
540
+ await this.gotoChannel(name, targetUrl);
541
+ }
542
+
543
+ const page = this.getPage(name);
544
+ if (typeof page?.bringToFront === 'function') {
545
+ await page.bringToFront();
546
+ }
547
+
548
+ return page;
549
+ }
550
+
551
+ annotatePage(page, name = 'chat') {
552
+ if (!page || typeof page !== 'object') {
553
+ return;
554
+ }
555
+
556
+ try {
557
+ page.__xshatChannelName = name;
558
+ page.__xshatBrowserManager = this;
559
+ } catch {
560
+ // ignore non-extensible page wrappers
561
+ }
562
+ }
563
+
564
+ attachChannelObservers(name = 'chat') {
565
+ const channel = this._getChannel(name);
566
+ const page = channel.page;
567
+ if (!page || typeof page.on !== 'function') {
568
+ return;
569
+ }
570
+
571
+ const observation = channel.observation;
572
+ const maxEntries = observation.maxEntries;
573
+ const markUpdated = () => {
574
+ observation.updatedAt = nowIso();
575
+ };
576
+
577
+ const pushEntry = (bucket, entry) => {
578
+ pushLimited(bucket, {
579
+ time: nowIso(),
580
+ ...entry
581
+ }, maxEntries);
582
+ markUpdated();
583
+ };
584
+
585
+ page.on('request', (request) => {
586
+ try {
587
+ pushEntry(observation.requests, {
588
+ method: request?.method?.() || 'GET',
589
+ url: request?.url?.() || '',
590
+ resourceType: request?.resourceType?.() || '',
591
+ navigation: Boolean(request?.isNavigationRequest?.())
592
+ });
593
+ } catch {
594
+ // ignore observer failures
595
+ }
596
+ });
597
+
598
+ page.on('requestfailed', (request) => {
599
+ try {
600
+ pushEntry(observation.requests, {
601
+ method: request?.method?.() || 'GET',
602
+ url: request?.url?.() || '',
603
+ resourceType: request?.resourceType?.() || '',
604
+ navigation: Boolean(request?.isNavigationRequest?.()),
605
+ failed: true,
606
+ failureText: request?.failure?.()?.errorText || 'request failed'
607
+ });
608
+ } catch {
609
+ // ignore observer failures
610
+ }
611
+ });
612
+
613
+ page.on('response', (response) => {
614
+ const entry = {
615
+ time: nowIso(),
616
+ url: response?.url?.() || '',
617
+ status: response?.status?.() || 0,
618
+ ok: Boolean(response?.ok?.()),
619
+ method: response?.request?.()?.method?.() || 'GET',
620
+ resourceType: response?.request?.()?.resourceType?.() || '',
621
+ contentType: '',
622
+ bodyPreview: '',
623
+ bodyCaptured: false
624
+ };
625
+ pushLimited(observation.responses, entry, maxEntries);
626
+ markUpdated();
627
+
628
+ Promise.resolve()
629
+ .then(async () => {
630
+ const headers = await response.headers();
631
+ entry.contentType = pickHeader(headers, 'content-type');
632
+
633
+ if (!isTextLikeContentType(entry.contentType)) {
634
+ return;
635
+ }
636
+
637
+ const bodyText = await response.text();
638
+ entry.bodyPreview = clipText(bodyText, 500);
639
+ entry.bodyCaptured = true;
640
+ markUpdated();
641
+ })
642
+ .catch(() => {});
643
+ });
644
+
645
+ page.on('console', async (message) => {
646
+ try {
647
+ const type = message?.type?.() || 'log';
648
+ const text = clipText(message?.text?.() || '', 300);
649
+ if (!text) {
650
+ return;
651
+ }
652
+
653
+ pushEntry(observation.console, {
654
+ type,
655
+ text
656
+ });
657
+ } catch {
658
+ // ignore observer failures
659
+ }
660
+ });
661
+
662
+ page.on('pageerror', (error) => {
663
+ try {
664
+ pushEntry(observation.pageErrors, {
665
+ message: clipText(error?.message || String(error), 400)
666
+ });
667
+ } catch {
668
+ // ignore observer failures
669
+ }
670
+ });
671
+
672
+ page.on('websocket', (socket) => {
673
+ try {
674
+ pushEntry(observation.websockets, {
675
+ url: socket?.url?.() || ''
676
+ });
677
+ } catch {
678
+ // ignore observer failures
679
+ }
680
+ });
681
+ }
682
+
683
+ async openAgentPage(url, overrides = {}) {
684
+ const agentWasReady = this.isChannelReady('agent');
685
+ const allowVisibleBrowser = this.browserConfig.allowVisibleBrowser !== false;
686
+
687
+ await this.initChannel('agent', {
688
+ headless: allowVisibleBrowser ? false : true,
689
+ ...overrides
690
+ });
691
+
692
+ if (
693
+ agentWasReady &&
694
+ this.browserConfig.shareAuthState &&
695
+ this.isChannelReady('chat')
696
+ ) {
697
+ await this.syncStorageState('chat', 'agent');
698
+ }
699
+
700
+ await this.navigateChannel('agent', url, {
701
+ background: true,
702
+ waitUntil: overrides.waitUntil || 'domcontentloaded',
703
+ timeout: overrides.timeout
704
+ });
705
+ const page = this.getPage('agent');
706
+ if (typeof page?.bringToFront === 'function') {
707
+ await page.bringToFront();
708
+ }
709
+ return page;
710
+ }
711
+
712
+ async exportStorageState(name = 'chat') {
713
+ const channel = this._getChannel(name);
714
+ if (!await this.probeChannel(name)) {
715
+ return null;
716
+ }
717
+
718
+ if (!channel.browser || typeof channel.browser.storageState !== 'function') {
719
+ return null;
720
+ }
721
+
722
+ return channel.browser.storageState();
723
+ }
724
+
725
+ async importStorageState(name = 'agent', state = null) {
726
+ const channel = this._getChannel(name);
727
+ if (!state) {
728
+ return false;
729
+ }
730
+
731
+ if (!await this.probeChannel(name)) {
732
+ return false;
733
+ }
734
+
735
+ if (!channel.browser) {
736
+ return false;
737
+ }
738
+
739
+ if (Array.isArray(state.cookies) && state.cookies.length > 0) {
740
+ if (typeof channel.browser.addCookies === 'function') {
741
+ try {
742
+ await channel.browser.addCookies(state.cookies);
743
+ } catch (error) {
744
+ if (this._isClosedError(error)) {
745
+ this._markChannelClosed(name);
746
+ return false;
747
+ }
748
+ throw error;
749
+ }
750
+ }
751
+ }
752
+
753
+ const originStates = localStorageEntriesFromState(state);
754
+ if (originStates.length === 0 || typeof channel.browser.newPage !== 'function') {
755
+ return true;
756
+ }
757
+
758
+ let tempPage;
759
+ try {
760
+ tempPage = await channel.browser.newPage();
761
+ } catch (error) {
762
+ if (this._isClosedError(error)) {
763
+ this._markChannelClosed(name);
764
+ return false;
765
+ }
766
+ throw error;
767
+ }
768
+ try {
769
+ for (const originState of originStates) {
770
+ if (!originState.localStorage.length) {
771
+ continue;
772
+ }
773
+
774
+ try {
775
+ await tempPage.goto(originState.origin, {
776
+ waitUntil: 'domcontentloaded',
777
+ timeout: channel.config.gotoTimeout
778
+ });
779
+ await tempPage.evaluate((entries) => {
780
+ for (const item of entries) {
781
+ if (!item?.name) {
782
+ continue;
783
+ }
784
+ localStorage.setItem(item.name, String(item.value ?? ''));
785
+ }
786
+ }, originState.localStorage);
787
+ } catch {
788
+ // 某些站点不允许直接访问 origin 根路径,这里静默跳过,cookies 仍会保留。
789
+ }
790
+ }
791
+ } finally {
792
+ if (typeof tempPage.close === 'function') {
793
+ await tempPage.close().catch(() => {});
794
+ }
795
+ }
796
+
797
+ return true;
798
+ }
799
+
800
+ async syncStorageState(from = 'chat', to = 'agent') {
801
+ if (from === to) {
802
+ return false;
803
+ }
804
+
805
+ const sourceReady = await this.probeChannel(from).catch(() => false);
806
+ const targetReady = await this.probeChannel(to).catch(() => false);
807
+ if (!sourceReady || !targetReady) {
808
+ return false;
809
+ }
810
+
811
+ const state = await this.exportStorageState(from);
812
+ if (!state) {
813
+ return false;
814
+ }
815
+
816
+ return this.importStorageState(to, state);
817
+ }
818
+
819
+ async waitForChannelNavigation(name = 'agent') {
820
+ const channel = this._getChannel(name);
821
+ if (channel.pendingNavigation) {
822
+ await channel.pendingNavigation;
823
+ }
824
+
825
+ return {
826
+ error: channel.lastNavigationError
827
+ };
828
+ }
829
+
830
+ getChannelObservation(name = 'chat', { limit = 5 } = {}) {
831
+ const channel = this._getChannel(name);
832
+ const observation = channel.observation || createObservationStore();
833
+ const slice = (items = []) => items.slice(-Math.max(1, limit));
834
+
835
+ return {
836
+ updatedAt: observation.updatedAt,
837
+ counts: {
838
+ requests: observation.requests.length,
839
+ responses: observation.responses.length,
840
+ console: observation.console.length,
841
+ pageErrors: observation.pageErrors.length,
842
+ websockets: observation.websockets.length
843
+ },
844
+ requests: slice(observation.requests),
845
+ responses: slice(observation.responses),
846
+ console: slice(observation.console),
847
+ pageErrors: slice(observation.pageErrors),
848
+ websockets: slice(observation.websockets)
849
+ };
850
+ }
851
+
852
+ async readChannelSnapshot(name = 'agent', {
853
+ maxChars = 4000,
854
+ includeHtml = false,
855
+ includeDomSummary = false,
856
+ includeObservation = true
857
+ } = {}) {
858
+ const page = this.getPage(name);
859
+ if (!page) {
860
+ throw new Error(`浏览器页面未初始化: ${name}`);
861
+ }
862
+
863
+ await this.waitForChannelNavigation(name);
864
+
865
+ const title = typeof page.title === 'function' ? await page.title() : '';
866
+ const url = typeof page.url === 'function' ? page.url() : '';
867
+ const bodyText = await page.evaluate(() => document.body?.innerText || '');
868
+ const text = String(bodyText ?? '').replace(/\s+/g, ' ').trim().slice(0, maxChars);
869
+ const snapshot = {
870
+ title,
871
+ url,
872
+ text
873
+ };
874
+
875
+ if (includeObservation) {
876
+ snapshot.observation = this.getChannelObservation(name);
877
+ }
878
+
879
+ if (includeHtml && typeof page.content === 'function') {
880
+ try {
881
+ snapshot.html = clipText(await page.content(), maxChars);
882
+ } catch {
883
+ snapshot.html = '';
884
+ }
885
+ }
886
+
887
+ if (includeDomSummary) {
888
+ snapshot.dom = await this.inspectDom(name, {
889
+ selector: 'body',
890
+ maxNodes: 12,
891
+ textLimit: 80
892
+ });
893
+ }
894
+
895
+ return snapshot;
896
+ }
897
+
898
+ async clickSelector(name = 'agent', selector, options = {}) {
899
+ const page = this.getPage(name);
900
+ if (!page) {
901
+ throw new Error(`浏览器页面未初始化: ${name}`);
902
+ }
903
+
904
+ await this.waitForChannelNavigation(name);
905
+ const selectors = splitSelectorCandidates(selector);
906
+ const timeout = options.timeout ?? 5000;
907
+ const maxAttempts = Math.max(
908
+ 1,
909
+ Number(options.retryCount ?? this.browserConfig.actionRetryCount) || 1
910
+ );
911
+ const retryDelayMs =
912
+ Number(options.retryDelayMs ?? this.browserConfig.actionRetryDelayMs) || 350;
913
+ let lastError = null;
914
+
915
+ for (const candidate of selectors) {
916
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
917
+ const locator = await pickBestLocator(page, candidate);
918
+
919
+ if (!locator) {
920
+ lastError = new Error(`未找到可点击元素: ${candidate}`);
921
+ break;
922
+ }
923
+
924
+ try {
925
+ await locator.waitFor({
926
+ state: options.state || 'attached',
927
+ timeout
928
+ });
929
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
930
+ if (typeof page.waitForTimeout === 'function') {
931
+ await page.waitForTimeout(Math.min(250, retryDelayMs));
932
+ }
933
+ await locator.click({
934
+ timeout,
935
+ force: options.force ?? false
936
+ });
937
+ if (options.waitForNavigation || options.afterWaitForNavigation) {
938
+ await page.waitForLoadState(options.navigationState || 'domcontentloaded', {
939
+ timeout: options.navigationTimeout ?? timeout
940
+ }).catch(() => {});
941
+ } else if (typeof page.waitForTimeout === 'function') {
942
+ await page.waitForTimeout(Math.min(300, retryDelayMs));
943
+ }
944
+ return;
945
+ } catch (error) {
946
+ lastError = error;
947
+
948
+ if (!isRecoverableActionError(error) || attempt >= maxAttempts) {
949
+ break;
950
+ }
951
+
952
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
953
+ await page.waitForTimeout(retryDelayMs);
954
+
955
+ try {
956
+ await locator.click({
957
+ timeout,
958
+ force: true
959
+ });
960
+ return;
961
+ } catch (forcedError) {
962
+ lastError = forcedError;
963
+
964
+ if (!isRecoverableActionError(forcedError) || attempt >= maxAttempts) {
965
+ break;
966
+ }
967
+
968
+ await page.waitForTimeout(retryDelayMs);
969
+ }
970
+ }
971
+ }
972
+ }
973
+
974
+ throw lastError || new Error(`点击失败: ${selector}`);
975
+ }
976
+
977
+ async typeSelector(name = 'agent', selector, text, options = {}) {
978
+ const page = this.getPage(name);
979
+ if (!page) {
980
+ throw new Error(`浏览器页面未初始化: ${name}`);
981
+ }
982
+
983
+ await this.waitForChannelNavigation(name);
984
+ const selectors = splitSelectorCandidates(selector);
985
+ const timeout = options.timeout ?? 5000;
986
+ let locator = null;
987
+
988
+ for (const candidate of selectors) {
989
+ locator = await pickBestLocator(page, candidate, { requireEditable: true });
990
+ if (locator) {
991
+ break;
992
+ }
993
+ }
994
+
995
+ if (!locator) {
996
+ locator = await findEditableFallbackLocator(page);
997
+ }
998
+
999
+ if (!locator) {
1000
+ throw new Error(`未找到可输入元素: ${selector}(已尝试通用输入框回退)`);
1001
+ }
1002
+
1003
+ await locator.waitFor({
1004
+ state: options.state || 'attached',
1005
+ timeout
1006
+ });
1007
+ await locator.scrollIntoViewIfNeeded().catch(() => {});
1008
+ await locator.click({
1009
+ timeout,
1010
+ force: options.force ?? true
1011
+ });
1012
+ if (typeof locator.focus === 'function') {
1013
+ await locator.focus().catch(() => {});
1014
+ }
1015
+
1016
+ if (options.clear !== false) {
1017
+ const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
1018
+ await page.keyboard.press(`${modifier}+A`).catch(() => {});
1019
+ await page.keyboard.press('Backspace').catch(() => {});
1020
+ }
1021
+
1022
+ const inputValue = String(text ?? '');
1023
+
1024
+ try {
1025
+ if (typeof locator.fill === 'function') {
1026
+ await locator.fill(inputValue);
1027
+ } else if (page.keyboard?.insertText) {
1028
+ await page.keyboard.insertText(inputValue);
1029
+ } else if (page.keyboard?.type) {
1030
+ await page.keyboard.type(inputValue);
1031
+ }
1032
+ } catch {
1033
+ await locator.evaluate((node, value) => {
1034
+ if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
1035
+ node.value = value;
1036
+ node.dispatchEvent(new Event('input', { bubbles: true }));
1037
+ node.dispatchEvent(new Event('change', { bubbles: true }));
1038
+ return;
1039
+ }
1040
+
1041
+ if (node instanceof HTMLElement) {
1042
+ node.focus();
1043
+ node.textContent = value;
1044
+ node.dispatchEvent(new InputEvent('input', {
1045
+ bubbles: true,
1046
+ data: value,
1047
+ inputType: 'insertText'
1048
+ }));
1049
+ }
1050
+ }, inputValue);
1051
+ }
1052
+
1053
+ if (options.pressEnter || options.submit) {
1054
+ await page.keyboard.press(options.submitKey || 'Enter').catch(() => {});
1055
+ }
1056
+
1057
+ if (typeof page.waitForTimeout === 'function') {
1058
+ await page.waitForTimeout(150);
1059
+ }
1060
+ }
1061
+
1062
+ async waitForText(name = 'agent', text, options = {}) {
1063
+ const page = this.getPage(name);
1064
+ if (!page) {
1065
+ throw new Error(`浏览器页面未初始化: ${name}`);
1066
+ }
1067
+
1068
+ await this.waitForChannelNavigation(name);
1069
+
1070
+ const timeout = options.timeout ?? 10000;
1071
+ if (options.selector) {
1072
+ await page.locator(options.selector).filter({ hasText: text }).first().waitFor({
1073
+ timeout
1074
+ });
1075
+ return;
1076
+ }
1077
+
1078
+ await page.waitForFunction(
1079
+ (expected) => document.body?.innerText?.includes(expected),
1080
+ text,
1081
+ { timeout }
1082
+ );
1083
+ }
1084
+
1085
+ async waitForSelector(name = 'agent', selector, options = {}) {
1086
+ const page = this.getPage(name);
1087
+ if (!page) {
1088
+ throw new Error(`浏览器页面未初始化: ${name}`);
1089
+ }
1090
+
1091
+ await this.waitForChannelNavigation(name);
1092
+ const selectors = splitSelectorCandidates(selector);
1093
+ let lastError = null;
1094
+
1095
+ for (const candidate of selectors) {
1096
+ try {
1097
+ await page.locator(candidate).first().waitFor({
1098
+ state: options.state || 'visible',
1099
+ timeout: options.timeout ?? 10000
1100
+ });
1101
+ return;
1102
+ } catch (error) {
1103
+ lastError = error;
1104
+ }
1105
+ }
1106
+
1107
+ throw lastError || new Error(`未等到选择器出现: ${selector}`);
1108
+ }
1109
+
1110
+ async waitForNavigation(name = 'agent', options = {}) {
1111
+ const page = this.getPage(name);
1112
+ if (!page) {
1113
+ throw new Error(`浏览器页面未初始化: ${name}`);
1114
+ }
1115
+
1116
+ await this.waitForChannelNavigation(name);
1117
+ await page.waitForLoadState(options.state || 'domcontentloaded', {
1118
+ timeout: options.timeout ?? 10000
1119
+ });
1120
+ }
1121
+
1122
+ async pressKey(name = 'agent', key, options = {}) {
1123
+ const page = this.getPage(name);
1124
+ if (!page) {
1125
+ throw new Error(`浏览器页面未初始化: ${name}`);
1126
+ }
1127
+
1128
+ await this.waitForChannelNavigation(name);
1129
+
1130
+ if (options.selector) {
1131
+ const locator = page.locator(options.selector).first();
1132
+ await locator.click({
1133
+ timeout: options.timeout ?? 5000,
1134
+ force: options.force ?? true
1135
+ });
1136
+ if (typeof locator.focus === 'function') {
1137
+ await locator.focus();
1138
+ }
1139
+ }
1140
+
1141
+ const repeat = Math.max(1, Number(options.repeat) || 1);
1142
+ for (let index = 0; index < repeat; index++) {
1143
+ await page.keyboard.press(key);
1144
+ }
1145
+ }
1146
+
1147
+ async hoverSelector(name = 'agent', selector, options = {}) {
1148
+ const page = this.getPage(name);
1149
+ if (!page) {
1150
+ throw new Error(`浏览器页面未初始化: ${name}`);
1151
+ }
1152
+
1153
+ await this.waitForChannelNavigation(name);
1154
+ await page.locator(selector).first().hover({
1155
+ timeout: options.timeout ?? 5000,
1156
+ force: options.force ?? true
1157
+ });
1158
+ }
1159
+
1160
+ async setCheckbox(name = 'agent', selector, checked = true, options = {}) {
1161
+ const page = this.getPage(name);
1162
+ if (!page) {
1163
+ throw new Error(`浏览器页面未初始化: ${name}`);
1164
+ }
1165
+
1166
+ await this.waitForChannelNavigation(name);
1167
+ const locator = page.locator(selector).first();
1168
+ const method = checked ? 'check' : 'uncheck';
1169
+ await locator[method]({
1170
+ timeout: options.timeout ?? 5000,
1171
+ force: options.force ?? true
1172
+ });
1173
+ }
1174
+
1175
+ async selectOption(name = 'agent', selector, value, options = {}) {
1176
+ const page = this.getPage(name);
1177
+ if (!page) {
1178
+ throw new Error(`浏览器页面未初始化: ${name}`);
1179
+ }
1180
+
1181
+ await this.waitForChannelNavigation(name);
1182
+ const locator = page.locator(selector).first();
1183
+ const payload =
1184
+ value && typeof value === 'object'
1185
+ ? value
1186
+ : { value: String(value ?? '') };
1187
+
1188
+ await locator.selectOption(payload, {
1189
+ timeout: options.timeout ?? 5000
1190
+ });
1191
+ }
1192
+
1193
+ async scrollPage(name = 'agent', options = {}) {
1194
+ const page = this.getPage(name);
1195
+ if (!page) {
1196
+ throw new Error(`浏览器页面未初始化: ${name}`);
1197
+ }
1198
+
1199
+ await this.waitForChannelNavigation(name);
1200
+
1201
+ if (options.selector) {
1202
+ await page.locator(options.selector).first().scrollIntoViewIfNeeded();
1203
+ return;
1204
+ }
1205
+
1206
+ const x = Number(options.x ?? options.left ?? 0);
1207
+ const y = Number(options.y ?? options.top ?? 0);
1208
+ const behavior = options.behavior === 'smooth' ? 'smooth' : 'auto';
1209
+ const mode = options.mode === 'to' ? 'to' : 'by';
1210
+
1211
+ await page.evaluate(
1212
+ ({ scrollX, scrollY, scrollBehavior, scrollMode }) => {
1213
+ if (scrollMode === 'to') {
1214
+ window.scrollTo({
1215
+ left: scrollX,
1216
+ top: scrollY,
1217
+ behavior: scrollBehavior
1218
+ });
1219
+ return;
1220
+ }
1221
+
1222
+ window.scrollBy({
1223
+ left: scrollX,
1224
+ top: scrollY,
1225
+ behavior: scrollBehavior
1226
+ });
1227
+ },
1228
+ {
1229
+ scrollX: x,
1230
+ scrollY: y,
1231
+ scrollBehavior: behavior,
1232
+ scrollMode: mode
1233
+ }
1234
+ );
1235
+ }
1236
+
1237
+ async extractSelectorText(name = 'agent', selector, options = {}) {
1238
+ const page = this.getPage(name);
1239
+ if (!page) {
1240
+ throw new Error(`浏览器页面未初始化: ${name}`);
1241
+ }
1242
+
1243
+ await this.waitForChannelNavigation(name);
1244
+ const selectors = splitSelectorCandidates(selector);
1245
+ const texts = [];
1246
+
1247
+ for (const candidate of selectors) {
1248
+ const locator = page.locator(candidate);
1249
+ const count = await locator.count().catch(() => 0);
1250
+ const limit = options.all ? count : Math.min(count, 1);
1251
+
1252
+ for (let index = 0; index < limit; index++) {
1253
+ const text = await locator.nth(index).textContent().catch(() => '');
1254
+ const normalized = String(text ?? '').trim();
1255
+ if (normalized) {
1256
+ texts.push(normalized);
1257
+ }
1258
+ }
1259
+
1260
+ if (texts.length > 0 && !options.all) {
1261
+ break;
1262
+ }
1263
+ }
1264
+
1265
+ return options.all ? texts.filter(Boolean) : (texts[0] || '');
1266
+ }
1267
+
1268
+ async screenshotChannel(name = 'agent', options = {}) {
1269
+ const page = this.getPage(name);
1270
+ if (!page) {
1271
+ throw new Error(`浏览器页面未初始化: ${name}`);
1272
+ }
1273
+
1274
+ await this.waitForChannelNavigation(name);
1275
+ await page.screenshot({
1276
+ path: options.path,
1277
+ fullPage: options.fullPage !== false
1278
+ });
1279
+ }
1280
+
1281
+ async inspectDom(name = 'agent', options = {}) {
1282
+ const page = this.getPage(name);
1283
+ if (!page) {
1284
+ throw new Error(`浏览器页面未初始化: ${name}`);
1285
+ }
1286
+
1287
+ await this.waitForChannelNavigation(name);
1288
+ const selector = options.selector || 'body';
1289
+ const maxNodes = Math.max(1, Number(options.maxNodes) || 20);
1290
+ const textLimit = Math.max(20, Number(options.textLimit) || 120);
1291
+
1292
+ return page.evaluate(
1293
+ ({ rootSelector, maxItems, maxTextLength }) => {
1294
+ const root = document.querySelector(rootSelector);
1295
+ if (!root) {
1296
+ return [];
1297
+ }
1298
+
1299
+ const nodes = [root, ...Array.from(root.querySelectorAll('*'))].slice(0, maxItems);
1300
+ return nodes.map((node) => ({
1301
+ tag: node.tagName.toLowerCase(),
1302
+ id: node.id || '',
1303
+ classes: node.className
1304
+ ? String(node.className)
1305
+ .split(/\s+/)
1306
+ .filter(Boolean)
1307
+ : [],
1308
+ text: (node.innerText || node.textContent || '')
1309
+ .replace(/\s+/g, ' ')
1310
+ .trim()
1311
+ .slice(0, maxTextLength),
1312
+ name: node.getAttribute('name') || '',
1313
+ role: node.getAttribute('role') || '',
1314
+ href: node.getAttribute('href') || '',
1315
+ type: node.getAttribute('type') || '',
1316
+ placeholder: node.getAttribute('placeholder') || '',
1317
+ ariaLabel: node.getAttribute('aria-label') || '',
1318
+ title: node.getAttribute('title') || '',
1319
+ value:
1320
+ node instanceof HTMLInputElement ||
1321
+ node instanceof HTMLTextAreaElement
1322
+ ? (node.value || '').slice(0, maxTextLength)
1323
+ : '',
1324
+ contenteditable: node.getAttribute('contenteditable') || '',
1325
+ visible: (() => {
1326
+ const rect = node.getBoundingClientRect();
1327
+ const style = window.getComputedStyle(node);
1328
+ return (
1329
+ rect.width > 0 &&
1330
+ rect.height > 0 &&
1331
+ style.visibility !== 'hidden' &&
1332
+ style.display !== 'none'
1333
+ );
1334
+ })()
1335
+ }));
1336
+ },
1337
+ {
1338
+ rootSelector: selector,
1339
+ maxItems: maxNodes,
1340
+ maxTextLength: textLimit
1341
+ }
1342
+ );
1343
+ }
1344
+
1345
+ getChannelState(name = 'agent') {
1346
+ const channel = this._getChannel(name);
1347
+ return {
1348
+ ready: this.isChannelReady(name),
1349
+ url: channel.page ? channel.page.url() : null,
1350
+ hasPendingNavigation: Boolean(channel.pendingNavigation),
1351
+ lastNavigationError: channel.lastNavigationError?.message || null,
1352
+ observation: this.getChannelObservation(name, { limit: 3 })
1353
+ };
1354
+ }
1355
+
1356
+ async close() {
1357
+ const names = [...this.channels.keys()];
1358
+ await Promise.all(names.map((name) => this.closeChannel(name)));
1359
+ }
1360
+
1361
+ async closeChannel(name = 'chat') {
1362
+ const channel = this._getChannel(name);
1363
+ if (channel.browser) {
1364
+ await channel.browser.close().catch(() => {});
1365
+ this._markChannelClosed(name);
1366
+ }
1367
+ }
1368
+
1369
+ getPage(name = 'chat') {
1370
+ const channel = this._getChannel(name);
1371
+ return channel.page;
1372
+ }
1373
+
1374
+ isReady() {
1375
+ return this.isChannelReady('chat');
1376
+ }
1377
+
1378
+ isChannelReady(name = 'chat') {
1379
+ const channel = this._getChannel(name);
1380
+ return channel.browser !== null && channel.page !== null;
1381
+ }
1382
+ }
1383
+
1384
+ export default new BrowserManager();