deckide 3.5.36 → 3.5.37
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.
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import fsSync from 'node:fs';
|
|
4
|
-
import net from 'node:net';
|
|
5
4
|
import os from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { WebSocket } from 'ws';
|
|
@@ -23,6 +22,22 @@ function sleep(ms) {
|
|
|
23
22
|
function isOpen(socket) {
|
|
24
23
|
return socket.readyState === WebSocket.OPEN;
|
|
25
24
|
}
|
|
25
|
+
function waitForSocketOpen(socket, timeoutMs = 10_000) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
socket.once('open', () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
socket.once('error', (error) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
26
41
|
function coercePositiveInt(value, fallback, min, max) {
|
|
27
42
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
28
43
|
return fallback;
|
|
@@ -105,35 +120,57 @@ async function findChromeExecutable() {
|
|
|
105
120
|
}
|
|
106
121
|
throw new Error(`AGENT_BROWSER_CHROME does not exist: ${process.env.AGENT_BROWSER_CHROME}`);
|
|
107
122
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
return findInPath([
|
|
123
|
+
// 本物の Google Chrome を最優先(指紋が自然でボット判定を通りやすい)。
|
|
124
|
+
// Playwright の Chromium はフォールバックに回す。
|
|
125
|
+
const systemChrome = await findInPath([
|
|
113
126
|
'google-chrome',
|
|
114
127
|
'google-chrome-stable',
|
|
115
128
|
'chromium',
|
|
116
129
|
'chromium-browser',
|
|
117
130
|
'chrome',
|
|
118
131
|
]);
|
|
132
|
+
if (systemChrome) {
|
|
133
|
+
return systemChrome;
|
|
134
|
+
}
|
|
135
|
+
return findPlaywrightChromium();
|
|
119
136
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
// MCP(chrome-devtools-mcp 等)が --browserUrl で確実に相乗りできるよう、
|
|
138
|
+
// CDP ポートは固定にする。AGENT_BROWSER_CDP_PORT で上書き可、既定 9222。
|
|
139
|
+
const DEFAULT_CDP_PORT = 9222;
|
|
140
|
+
function resolveCdpPort() {
|
|
141
|
+
const raw = process.env.AGENT_BROWSER_CDP_PORT;
|
|
142
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
143
|
+
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
return DEFAULT_CDP_PORT;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Chrome の起動コマンドを解決する。
|
|
150
|
+
* ボット判定対策として「実 Chrome を headful」で動かしたい。Linux サーバ等で
|
|
151
|
+
* ディスプレイが無い場合は xvfb-run で仮想ディスプレイ上の headful を使う。
|
|
152
|
+
* xvfb-run も無ければやむを得ず headless にフォールバックする。
|
|
153
|
+
*/
|
|
154
|
+
function resolveLaunchCommand(executable, chromeArgs) {
|
|
155
|
+
const hasDisplay = Boolean(process.env.DISPLAY);
|
|
156
|
+
if (process.platform === 'linux' && !hasDisplay) {
|
|
157
|
+
const xvfbRun = ['/usr/bin/xvfb-run', '/usr/local/bin/xvfb-run'].find((p) => fsSync.existsSync(p));
|
|
158
|
+
if (xvfbRun) {
|
|
159
|
+
return {
|
|
160
|
+
command: xvfbRun,
|
|
161
|
+
args: [
|
|
162
|
+
'-a',
|
|
163
|
+
'--server-args=-screen 0 1280x720x24 -nolisten tcp',
|
|
164
|
+
executable,
|
|
165
|
+
...chromeArgs,
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// ディスプレイも xvfb-run も無い → headless で動かす(Cloudflare は通りにくい)。
|
|
170
|
+
return { command: executable, args: ['--headless=new', ...chromeArgs] };
|
|
171
|
+
}
|
|
172
|
+
// ディスプレイがある(mac/win/Linux+X)→ そのまま headful 起動。
|
|
173
|
+
return { command: executable, args: chromeArgs };
|
|
137
174
|
}
|
|
138
175
|
async function fetchJson(url, options = {}, timeoutMs = 5000) {
|
|
139
176
|
const controller = new AbortController();
|
|
@@ -243,10 +280,27 @@ export class AgentBrowserService {
|
|
|
243
280
|
launching = null;
|
|
244
281
|
port = null;
|
|
245
282
|
executablePath = null;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
283
|
+
// Browser-level CDP connection used only to discover tabs (page targets) and
|
|
284
|
+
// drive their lifecycle (create/close/activate). One per running Chrome.
|
|
285
|
+
browserCdp = null;
|
|
286
|
+
browserCdpSocket = null;
|
|
287
|
+
browserCdpConnecting = null;
|
|
288
|
+
// CDP connection to the *active* tab's page target — the only tab that streams
|
|
289
|
+
// frames and receives input. Switching tabs tears this down and reconnects to
|
|
290
|
+
// the new target.
|
|
291
|
+
activeCdp = null;
|
|
292
|
+
activeCdpSocket = null;
|
|
293
|
+
activeConnecting = null;
|
|
294
|
+
// Bumped on every tab switch. A slower in-flight connect compares against it
|
|
295
|
+
// and bails if it lost the race, so it can't clobber the newer active tab.
|
|
296
|
+
activeGeneration = 0;
|
|
249
297
|
screencastStarted = false;
|
|
298
|
+
// Known page targets (tabs) keyed by targetId, in discovery/creation order.
|
|
299
|
+
tabs = new Map();
|
|
300
|
+
activeTargetId = null;
|
|
301
|
+
// True once the first active tab is established. Gates popup auto-follow so
|
|
302
|
+
// targets discovered during launch don't hijack focus before we've picked one.
|
|
303
|
+
ready = false;
|
|
250
304
|
// Raw JPEG bytes of the most recent screencast frame, kept so reconnecting or
|
|
251
305
|
// late-joining clients can be shown the current page immediately. Frames are
|
|
252
306
|
// pushed to clients as binary WebSocket messages (no base64) to cut ~33% size
|
|
@@ -257,13 +311,12 @@ export class AgentBrowserService {
|
|
|
257
311
|
frameFlushTimer = null;
|
|
258
312
|
clients = new Set();
|
|
259
313
|
viewport = { ...DEFAULT_VIEWPORT };
|
|
260
|
-
pageUrl = null;
|
|
261
|
-
pageTitle = null;
|
|
262
314
|
lastError;
|
|
263
315
|
audioRelay = new BrowserAudioRelay();
|
|
264
316
|
profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
|
|
265
317
|
outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
|
266
318
|
async getStatus() {
|
|
319
|
+
const active = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
267
320
|
return {
|
|
268
321
|
running: this.isRunning(),
|
|
269
322
|
launching: Boolean(this.launching),
|
|
@@ -271,15 +324,17 @@ export class AgentBrowserService {
|
|
|
271
324
|
outputDir: this.outputDir,
|
|
272
325
|
executablePath: this.executablePath ?? await findChromeExecutable().catch(() => null),
|
|
273
326
|
cdpUrl: this.port ? `http://127.0.0.1:${this.port}` : null,
|
|
274
|
-
pageUrl:
|
|
275
|
-
pageTitle:
|
|
327
|
+
pageUrl: active?.url ?? null,
|
|
328
|
+
pageTitle: active?.title ?? null,
|
|
329
|
+
tabs: [...this.tabs.values()].map((tab) => ({ ...tab })),
|
|
330
|
+
activeTabId: this.activeTargetId,
|
|
276
331
|
audio: await this.audioRelay.getStatus(),
|
|
277
332
|
error: this.lastError,
|
|
278
333
|
};
|
|
279
334
|
}
|
|
280
335
|
async start() {
|
|
281
336
|
if (this.isRunning()) {
|
|
282
|
-
await this.
|
|
337
|
+
await this.ensureSession();
|
|
283
338
|
if (this.clients.size > 0) {
|
|
284
339
|
await this.startScreencast();
|
|
285
340
|
}
|
|
@@ -309,12 +364,14 @@ export class AgentBrowserService {
|
|
|
309
364
|
}
|
|
310
365
|
}
|
|
311
366
|
async stop() {
|
|
312
|
-
this.
|
|
367
|
+
this.disconnectActiveCdp();
|
|
368
|
+
this.closeBrowserCdp();
|
|
313
369
|
const proc = this.chromeProcess;
|
|
314
370
|
this.chromeProcess = null;
|
|
315
371
|
this.port = null;
|
|
316
|
-
this.
|
|
317
|
-
this.
|
|
372
|
+
this.tabs.clear();
|
|
373
|
+
this.activeTargetId = null;
|
|
374
|
+
this.ready = false;
|
|
318
375
|
this.screencastStarted = false;
|
|
319
376
|
this.lastFrameBuffer = null;
|
|
320
377
|
this.pendingFrames.clear();
|
|
@@ -372,12 +429,46 @@ export class AgentBrowserService {
|
|
|
372
429
|
async navigate(input) {
|
|
373
430
|
const url = normalizeBrowserUrl(input);
|
|
374
431
|
await this.start();
|
|
375
|
-
await this.
|
|
376
|
-
await this.
|
|
377
|
-
this.
|
|
378
|
-
|
|
432
|
+
await this.ensureActiveCdp();
|
|
433
|
+
await this.activeCdp?.send('Page.navigate', { url });
|
|
434
|
+
const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
435
|
+
if (tab) {
|
|
436
|
+
tab.url = url;
|
|
437
|
+
}
|
|
438
|
+
await this.refreshActivePageInfo();
|
|
379
439
|
await this.broadcastStatus();
|
|
380
440
|
}
|
|
441
|
+
async newTab(input) {
|
|
442
|
+
await this.start();
|
|
443
|
+
await this.connectBrowserCdp();
|
|
444
|
+
const url = input ? normalizeBrowserUrl(input) : 'about:blank';
|
|
445
|
+
const result = await this.browserCdp?.send('Target.createTarget', { url });
|
|
446
|
+
const targetId = result?.targetId;
|
|
447
|
+
if (!targetId) {
|
|
448
|
+
throw new Error('Failed to open a new tab');
|
|
449
|
+
}
|
|
450
|
+
if (!this.tabs.has(targetId)) {
|
|
451
|
+
this.tabs.set(targetId, { id: targetId, url: url === 'about:blank' ? '' : url, title: '' });
|
|
452
|
+
}
|
|
453
|
+
await this.setActiveTab(targetId);
|
|
454
|
+
}
|
|
455
|
+
async closeTab(targetId) {
|
|
456
|
+
await this.connectBrowserCdp();
|
|
457
|
+
await this.browserCdp?.send('Target.closeTarget', { targetId }).catch(() => undefined);
|
|
458
|
+
this.removeTab(targetId);
|
|
459
|
+
// Never leave the pane with zero tabs — reopen a blank one like a real
|
|
460
|
+
// browser keeping a minimum of one tab.
|
|
461
|
+
if (this.tabs.size === 0 && this.isRunning()) {
|
|
462
|
+
await this.newTab().catch(() => undefined);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async activateTab(targetId) {
|
|
466
|
+
if (!this.tabs.has(targetId)) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await this.setActiveTab(targetId);
|
|
470
|
+
await this.browserCdp?.send('Target.activateTarget', { targetId }).catch(() => undefined);
|
|
471
|
+
}
|
|
381
472
|
async launch() {
|
|
382
473
|
this.lastError = undefined;
|
|
383
474
|
await fs.mkdir(this.profileDir, { recursive: true });
|
|
@@ -387,10 +478,11 @@ export class AgentBrowserService {
|
|
|
387
478
|
throw new Error('Chromium or Chrome was not found. Set AGENT_BROWSER_CHROME to a browser executable.');
|
|
388
479
|
}
|
|
389
480
|
this.executablePath = executable;
|
|
390
|
-
this.port =
|
|
481
|
+
this.port = resolveCdpPort();
|
|
391
482
|
this.openLogStream();
|
|
392
|
-
|
|
393
|
-
|
|
483
|
+
// headless フラグは付けない(headful を既定にする)。ディスプレイ無し環境では
|
|
484
|
+
// resolveLaunchCommand が xvfb-run で仮想ディスプレイを用意する。
|
|
485
|
+
const chromeArgs = [
|
|
394
486
|
`--remote-debugging-address=127.0.0.1`,
|
|
395
487
|
`--remote-debugging-port=${this.port}`,
|
|
396
488
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -398,13 +490,23 @@ export class AgentBrowserService {
|
|
|
398
490
|
'--no-default-browser-check',
|
|
399
491
|
'--disable-dev-shm-usage',
|
|
400
492
|
'--autoplay-policy=no-user-gesture-required',
|
|
493
|
+
// GPU の無いサーバ + Xvfb 上で headful 起動すると、別プロセスの GPU が
|
|
494
|
+
// 起動失敗して "GPU process isn't usable. Goodbye." で即クラッシュする。
|
|
495
|
+
// GPU を browser プロセス内で動かし、GPU サンドボックスを外して回避する。
|
|
496
|
+
'--in-process-gpu',
|
|
497
|
+
'--disable-gpu-sandbox',
|
|
498
|
+
// ボット判定(Cloudflare の Managed Challenge 等)で誤検知されにくくする。
|
|
499
|
+
// navigator.webdriver を立てる自動化フラグを無効化する。残りの偽装は CDP 側。
|
|
500
|
+
'--disable-blink-features=AutomationControlled',
|
|
501
|
+
'--lang=ja-JP',
|
|
401
502
|
'--window-size=1280,720',
|
|
402
|
-
'about:blank',
|
|
403
503
|
];
|
|
404
504
|
if (process.getuid?.() === 0) {
|
|
405
|
-
|
|
505
|
+
chromeArgs.push('--no-sandbox');
|
|
406
506
|
}
|
|
407
|
-
|
|
507
|
+
chromeArgs.push('about:blank');
|
|
508
|
+
const { command, args } = resolveLaunchCommand(executable, chromeArgs);
|
|
509
|
+
const child = spawn(command, args, {
|
|
408
510
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
409
511
|
env: process.env,
|
|
410
512
|
});
|
|
@@ -415,7 +517,11 @@ export class AgentBrowserService {
|
|
|
415
517
|
if (this.chromeProcess === child) {
|
|
416
518
|
this.chromeProcess = null;
|
|
417
519
|
this.port = null;
|
|
418
|
-
this.
|
|
520
|
+
this.disconnectActiveCdp();
|
|
521
|
+
this.closeBrowserCdp();
|
|
522
|
+
this.tabs.clear();
|
|
523
|
+
this.activeTargetId = null;
|
|
524
|
+
this.ready = false;
|
|
419
525
|
this.closeLogStream();
|
|
420
526
|
this.lastError = code === 0 ? undefined : `Browser exited (${signal ?? code ?? 'unknown'})`;
|
|
421
527
|
void this.broadcastStatus();
|
|
@@ -426,8 +532,8 @@ export class AgentBrowserService {
|
|
|
426
532
|
void this.broadcastStatus();
|
|
427
533
|
});
|
|
428
534
|
await this.waitForChrome();
|
|
429
|
-
await this.
|
|
430
|
-
await this.
|
|
535
|
+
await this.connectBrowserCdp();
|
|
536
|
+
await this.ensureActiveTab();
|
|
431
537
|
await this.broadcastStatus();
|
|
432
538
|
}
|
|
433
539
|
async waitForChrome() {
|
|
@@ -455,7 +561,7 @@ export class AgentBrowserService {
|
|
|
455
561
|
async openForClient(socket) {
|
|
456
562
|
try {
|
|
457
563
|
await this.start();
|
|
458
|
-
await this.
|
|
564
|
+
await this.ensureActiveCdp();
|
|
459
565
|
await this.sendStatus(socket);
|
|
460
566
|
// Replay the most recent frame so a reconnecting or late-joining client
|
|
461
567
|
// sees the current page immediately. Screencast only emits frames on
|
|
@@ -479,80 +585,258 @@ export class AgentBrowserService {
|
|
|
479
585
|
const proc = this.chromeProcess;
|
|
480
586
|
return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
|
|
481
587
|
}
|
|
482
|
-
|
|
483
|
-
|
|
588
|
+
// Make sure the browser-level connection and an active tab (+ its CDP) exist.
|
|
589
|
+
async ensureSession() {
|
|
590
|
+
await this.connectBrowserCdp();
|
|
591
|
+
await this.ensureActiveTab();
|
|
592
|
+
}
|
|
593
|
+
async connectBrowserCdp() {
|
|
594
|
+
if (this.browserCdp && this.browserCdpSocket && isOpen(this.browserCdpSocket)) {
|
|
484
595
|
return;
|
|
485
596
|
}
|
|
486
|
-
if (this.
|
|
487
|
-
return this.
|
|
597
|
+
if (this.browserCdpConnecting) {
|
|
598
|
+
return this.browserCdpConnecting;
|
|
488
599
|
}
|
|
489
|
-
this.
|
|
600
|
+
this.browserCdpConnecting = this.doConnectBrowserCdp();
|
|
490
601
|
try {
|
|
491
|
-
await this.
|
|
602
|
+
await this.browserCdpConnecting;
|
|
492
603
|
}
|
|
493
604
|
finally {
|
|
494
|
-
this.
|
|
605
|
+
this.browserCdpConnecting = null;
|
|
495
606
|
}
|
|
496
607
|
}
|
|
497
|
-
async
|
|
608
|
+
async doConnectBrowserCdp() {
|
|
498
609
|
if (!this.port) {
|
|
499
610
|
throw new Error('Browser is not running');
|
|
500
611
|
}
|
|
501
|
-
const
|
|
502
|
-
if (!
|
|
503
|
-
throw new Error('Chrome
|
|
612
|
+
const version = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
|
|
613
|
+
if (!version.webSocketDebuggerUrl) {
|
|
614
|
+
throw new Error('Chrome has no browser-level DevTools endpoint');
|
|
504
615
|
}
|
|
505
|
-
this.
|
|
506
|
-
const socket = new WebSocket(
|
|
507
|
-
await
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
reject(error);
|
|
518
|
-
});
|
|
616
|
+
this.closeBrowserCdp();
|
|
617
|
+
const socket = new WebSocket(version.webSocketDebuggerUrl);
|
|
618
|
+
await waitForSocketOpen(socket);
|
|
619
|
+
const cdp = new CdpConnection(socket);
|
|
620
|
+
cdp.onEvent = (message) => this.handleBrowserEvent(message);
|
|
621
|
+
this.browserCdp = cdp;
|
|
622
|
+
this.browserCdpSocket = socket;
|
|
623
|
+
socket.on('close', () => {
|
|
624
|
+
if (this.browserCdpSocket === socket) {
|
|
625
|
+
this.browserCdp = null;
|
|
626
|
+
this.browserCdpSocket = null;
|
|
627
|
+
}
|
|
519
628
|
});
|
|
629
|
+
// Emits Target.targetCreated for every existing target, then keeps us posted
|
|
630
|
+
// on creation/url/title changes and destruction.
|
|
631
|
+
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
632
|
+
}
|
|
633
|
+
async ensureActiveTab() {
|
|
634
|
+
await this.connectBrowserCdp();
|
|
635
|
+
// Discovery events normally populate `tabs`; if they haven't landed yet (or
|
|
636
|
+
// none exist), fall back to the HTTP target list, then create one.
|
|
637
|
+
if (this.tabs.size === 0) {
|
|
638
|
+
const targets = await fetchJson(`http://127.0.0.1:${this.port}/json/list`)
|
|
639
|
+
.catch(() => []);
|
|
640
|
+
for (const target of targets) {
|
|
641
|
+
if (target.type === 'page' && !target.url.startsWith('devtools://')) {
|
|
642
|
+
this.tabs.set(target.id, { id: target.id, url: target.url, title: target.title ?? '' });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (this.tabs.size === 0) {
|
|
647
|
+
const created = await this.browserCdp?.send('Target.createTarget', {
|
|
648
|
+
url: 'about:blank',
|
|
649
|
+
});
|
|
650
|
+
const targetId = created?.targetId;
|
|
651
|
+
if (!targetId) {
|
|
652
|
+
throw new Error('Failed to open a browser tab');
|
|
653
|
+
}
|
|
654
|
+
this.tabs.set(targetId, { id: targetId, url: '', title: '' });
|
|
655
|
+
await this.setActiveTab(targetId);
|
|
656
|
+
this.ready = true;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
|
|
660
|
+
const first = this.tabs.keys().next().value;
|
|
661
|
+
await this.setActiveTab(first);
|
|
662
|
+
}
|
|
663
|
+
else if (!this.activeCdp || !this.activeCdpSocket || !isOpen(this.activeCdpSocket)) {
|
|
664
|
+
await this.connectActiveCdp(this.activeTargetId);
|
|
665
|
+
}
|
|
666
|
+
this.ready = true;
|
|
667
|
+
}
|
|
668
|
+
async ensureActiveCdp() {
|
|
669
|
+
if (this.activeCdp && this.activeCdpSocket && isOpen(this.activeCdpSocket)) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (this.activeConnecting) {
|
|
673
|
+
return this.activeConnecting;
|
|
674
|
+
}
|
|
675
|
+
this.activeConnecting = (async () => {
|
|
676
|
+
if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
|
|
677
|
+
await this.ensureActiveTab();
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
await this.connectActiveCdp(this.activeTargetId);
|
|
681
|
+
}
|
|
682
|
+
})();
|
|
683
|
+
try {
|
|
684
|
+
await this.activeConnecting;
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
this.activeConnecting = null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async setActiveTab(targetId) {
|
|
691
|
+
this.activeGeneration += 1;
|
|
692
|
+
const generation = this.activeGeneration;
|
|
693
|
+
this.activeTargetId = targetId;
|
|
694
|
+
this.clearActiveFrame();
|
|
695
|
+
// Drop the stale page from every client while the new tab's first frame is
|
|
696
|
+
// still in flight.
|
|
697
|
+
this.broadcast({ type: 'clear' });
|
|
698
|
+
try {
|
|
699
|
+
await this.connectActiveCdp(targetId, generation);
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (generation === this.activeGeneration) {
|
|
703
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await this.broadcastStatus();
|
|
707
|
+
}
|
|
708
|
+
async connectActiveCdp(targetId, generation = this.activeGeneration) {
|
|
709
|
+
if (!this.port) {
|
|
710
|
+
throw new Error('Browser is not running');
|
|
711
|
+
}
|
|
712
|
+
this.disconnectActiveCdp();
|
|
713
|
+
const socket = new WebSocket(`ws://127.0.0.1:${this.port}/devtools/page/${targetId}`);
|
|
714
|
+
try {
|
|
715
|
+
await waitForSocketOpen(socket);
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
try {
|
|
719
|
+
socket.close();
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// ignore
|
|
723
|
+
}
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
// A newer switch started while we were connecting — discard this one.
|
|
727
|
+
if (generation !== this.activeGeneration) {
|
|
728
|
+
try {
|
|
729
|
+
socket.close();
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// ignore
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
520
736
|
const cdp = new CdpConnection(socket);
|
|
521
|
-
cdp.onEvent = (message) => this.
|
|
522
|
-
this.
|
|
523
|
-
this.
|
|
737
|
+
cdp.onEvent = (message) => this.handleActiveEvent(message);
|
|
738
|
+
this.activeCdp = cdp;
|
|
739
|
+
this.activeCdpSocket = socket;
|
|
524
740
|
this.screencastStarted = false;
|
|
525
|
-
this.pageUrl = target.url || this.pageUrl;
|
|
526
|
-
this.pageTitle = target.title || this.pageTitle;
|
|
527
741
|
socket.on('close', () => {
|
|
528
|
-
if (this.
|
|
529
|
-
this.
|
|
530
|
-
this.
|
|
742
|
+
if (this.activeCdpSocket === socket) {
|
|
743
|
+
this.activeCdp = null;
|
|
744
|
+
this.activeCdpSocket = null;
|
|
531
745
|
this.screencastStarted = false;
|
|
532
746
|
}
|
|
533
747
|
});
|
|
534
748
|
await cdp.send('Page.enable');
|
|
535
749
|
await cdp.send('Runtime.enable');
|
|
750
|
+
await this.applyAntiDetection(cdp);
|
|
751
|
+
if (generation !== this.activeGeneration) {
|
|
752
|
+
this.disconnectActiveCdp();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
536
755
|
await this.applyViewport();
|
|
756
|
+
if (this.clients.size > 0) {
|
|
757
|
+
await this.startScreencast();
|
|
758
|
+
}
|
|
537
759
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
760
|
+
/**
|
|
761
|
+
* ヘッドレス Chrome がボット判定(Cloudflare の Managed Challenge / Turnstile 等)で
|
|
762
|
+
* 誤検知されるのを減らす。完全な突破は保証できないが、代表的な自動化シグナルを消す:
|
|
763
|
+
* - UA から "HeadlessChrome" を除去(実バージョンに一致させる)
|
|
764
|
+
* - navigator.webdriver / languages / chrome / plugins / WebGL ベンダを通常ブラウザ相当に
|
|
765
|
+
* 偽装スクリプトは addScriptToEvaluateOnNewDocument で「以後の」ページ生成時に毎回適用される。
|
|
766
|
+
*/
|
|
767
|
+
async applyAntiDetection(cdp) {
|
|
768
|
+
try {
|
|
769
|
+
// 実際のブラウザ版に一致した UA を使う(バージョン不一致自体が検知シグナルになるため)。
|
|
770
|
+
// ページセッションでは Browser.* が使えないことがあるので /json/version を優先。
|
|
771
|
+
let userAgent = '';
|
|
772
|
+
try {
|
|
773
|
+
const info = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
|
|
774
|
+
userAgent = (info['User-Agent'] || '').replace(/HeadlessChrome/gi, 'Chrome');
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// フォールバック: ページの navigator.userAgent を取得して整形。
|
|
778
|
+
const r = await cdp.send('Runtime.evaluate', {
|
|
779
|
+
expression: 'navigator.userAgent',
|
|
780
|
+
returnByValue: true,
|
|
781
|
+
});
|
|
782
|
+
userAgent = String(r.result?.value ?? '').replace(/HeadlessChrome/gi, 'Chrome');
|
|
783
|
+
}
|
|
784
|
+
const major = (userAgent.match(/Chrome\/(\d+)/) || [])[1] || '120';
|
|
785
|
+
if (userAgent) {
|
|
786
|
+
await cdp.send('Emulation.setUserAgentOverride', {
|
|
787
|
+
userAgent,
|
|
788
|
+
acceptLanguage: 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
789
|
+
platform: 'Linux x86_64',
|
|
790
|
+
userAgentMetadata: {
|
|
791
|
+
brands: [
|
|
792
|
+
{ brand: 'Not_A Brand', version: '24' },
|
|
793
|
+
{ brand: 'Chromium', version: major },
|
|
794
|
+
{ brand: 'Google Chrome', version: major },
|
|
795
|
+
],
|
|
796
|
+
fullVersion: (userAgent.match(/Chrome\/([\d.]+)/) || [])[1] || `${major}.0.0.0`,
|
|
797
|
+
platform: 'Linux',
|
|
798
|
+
platformVersion: '',
|
|
799
|
+
architecture: 'x86',
|
|
800
|
+
model: '',
|
|
801
|
+
mobile: false,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const stealthSource = `
|
|
806
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
807
|
+
if (!window.chrome) { window.chrome = { runtime: {} }; }
|
|
808
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['ja-JP', 'ja', 'en-US', 'en'] });
|
|
809
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
810
|
+
try {
|
|
811
|
+
const _q = window.navigator.permissions && window.navigator.permissions.query;
|
|
812
|
+
if (_q) {
|
|
813
|
+
window.navigator.permissions.query = (p) =>
|
|
814
|
+
p && p.name === 'notifications'
|
|
815
|
+
? Promise.resolve({ state: Notification.permission })
|
|
816
|
+
: _q(p);
|
|
817
|
+
}
|
|
818
|
+
} catch (e) { /* ignore */ }
|
|
819
|
+
try {
|
|
820
|
+
const getParam = WebGLRenderingContext.prototype.getParameter;
|
|
821
|
+
WebGLRenderingContext.prototype.getParameter = function (p) {
|
|
822
|
+
if (p === 37445) return 'Intel Inc.';
|
|
823
|
+
if (p === 37446) return 'Intel Iris OpenGL Engine';
|
|
824
|
+
return getParam.call(this, p);
|
|
825
|
+
};
|
|
826
|
+
} catch (e) { /* ignore */ }
|
|
827
|
+
`;
|
|
828
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: stealthSource });
|
|
541
829
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
|
|
545
|
-
if (page) {
|
|
546
|
-
return page;
|
|
830
|
+
catch {
|
|
831
|
+
// 反検知設定は best-effort。失敗してもブラウジング自体は続行する。
|
|
547
832
|
}
|
|
548
|
-
return fetchJson(`${baseUrl}/json/new?about:blank`, { method: 'PUT' });
|
|
549
833
|
}
|
|
550
834
|
async startScreencast() {
|
|
551
|
-
if (this.screencastStarted || !this.
|
|
835
|
+
if (this.screencastStarted || !this.activeCdp) {
|
|
552
836
|
return;
|
|
553
837
|
}
|
|
554
838
|
await this.applyViewport();
|
|
555
|
-
await this.
|
|
839
|
+
await this.activeCdp.send('Page.startScreencast', {
|
|
556
840
|
format: 'jpeg',
|
|
557
841
|
quality: 72,
|
|
558
842
|
maxWidth: this.viewport.width,
|
|
@@ -562,11 +846,11 @@ export class AgentBrowserService {
|
|
|
562
846
|
this.screencastStarted = true;
|
|
563
847
|
}
|
|
564
848
|
async stopScreencast() {
|
|
565
|
-
if (!this.screencastStarted || !this.
|
|
849
|
+
if (!this.screencastStarted || !this.activeCdp) {
|
|
566
850
|
return;
|
|
567
851
|
}
|
|
568
852
|
try {
|
|
569
|
-
await this.
|
|
853
|
+
await this.activeCdp.send('Page.stopScreencast');
|
|
570
854
|
}
|
|
571
855
|
catch {
|
|
572
856
|
// ignore
|
|
@@ -576,10 +860,10 @@ export class AgentBrowserService {
|
|
|
576
860
|
}
|
|
577
861
|
}
|
|
578
862
|
async applyViewport() {
|
|
579
|
-
if (!this.
|
|
863
|
+
if (!this.activeCdp) {
|
|
580
864
|
return;
|
|
581
865
|
}
|
|
582
|
-
await this.
|
|
866
|
+
await this.activeCdp.send('Emulation.setDeviceMetricsOverride', {
|
|
583
867
|
width: this.viewport.width,
|
|
584
868
|
height: this.viewport.height,
|
|
585
869
|
deviceScaleFactor: 1,
|
|
@@ -597,12 +881,12 @@ export class AgentBrowserService {
|
|
|
597
881
|
return;
|
|
598
882
|
}
|
|
599
883
|
this.viewport = next;
|
|
600
|
-
if (!this.
|
|
884
|
+
if (!this.activeCdp) {
|
|
601
885
|
return;
|
|
602
886
|
}
|
|
603
887
|
await this.applyViewport();
|
|
604
888
|
if (this.screencastStarted) {
|
|
605
|
-
await this.
|
|
889
|
+
await this.activeCdp.send('Page.stopScreencast').catch(() => undefined);
|
|
606
890
|
this.screencastStarted = false;
|
|
607
891
|
await this.startScreencast();
|
|
608
892
|
}
|
|
@@ -628,7 +912,19 @@ export class AgentBrowserService {
|
|
|
628
912
|
return;
|
|
629
913
|
}
|
|
630
914
|
await this.start();
|
|
631
|
-
|
|
915
|
+
if (message.type === 'new-tab') {
|
|
916
|
+
await this.newTab(message.url);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (message.type === 'close-tab') {
|
|
920
|
+
await this.closeTab(message.targetId);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (message.type === 'activate-tab') {
|
|
924
|
+
await this.activateTab(message.targetId);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
await this.ensureActiveCdp();
|
|
632
928
|
if (message.type === 'navigate') {
|
|
633
929
|
await this.navigate(message.url);
|
|
634
930
|
return;
|
|
@@ -649,9 +945,9 @@ export class AgentBrowserService {
|
|
|
649
945
|
}
|
|
650
946
|
}
|
|
651
947
|
async dispatchMouse(message) {
|
|
652
|
-
if (!this.
|
|
948
|
+
if (!this.activeCdp)
|
|
653
949
|
return;
|
|
654
|
-
await this.
|
|
950
|
+
await this.activeCdp.send('Input.dispatchMouseEvent', {
|
|
655
951
|
type: message.eventType,
|
|
656
952
|
x: coercePositiveInt(message.x, 0, 0, MAX_VIEWPORT.width),
|
|
657
953
|
y: coercePositiveInt(message.y, 0, 0, MAX_VIEWPORT.height),
|
|
@@ -663,10 +959,10 @@ export class AgentBrowserService {
|
|
|
663
959
|
});
|
|
664
960
|
}
|
|
665
961
|
async dispatchKey(message) {
|
|
666
|
-
if (!this.
|
|
962
|
+
if (!this.activeCdp)
|
|
667
963
|
return;
|
|
668
964
|
const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
|
|
669
|
-
await this.
|
|
965
|
+
await this.activeCdp.send('Input.dispatchKeyEvent', {
|
|
670
966
|
type: message.eventType,
|
|
671
967
|
key: message.key,
|
|
672
968
|
code: message.code,
|
|
@@ -677,13 +973,14 @@ export class AgentBrowserService {
|
|
|
677
973
|
modifiers: message.modifiers ?? 0,
|
|
678
974
|
});
|
|
679
975
|
}
|
|
680
|
-
|
|
976
|
+
// Events from the active tab's page CDP connection.
|
|
977
|
+
handleActiveEvent(message) {
|
|
681
978
|
if (message.method === 'Page.screencastFrame') {
|
|
682
979
|
const params = message.params;
|
|
683
980
|
const data = typeof params.data === 'string' ? params.data : null;
|
|
684
981
|
const sessionId = typeof params.sessionId === 'number' ? params.sessionId : null;
|
|
685
982
|
if (sessionId != null) {
|
|
686
|
-
void this.
|
|
983
|
+
void this.activeCdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
|
|
687
984
|
}
|
|
688
985
|
if (data) {
|
|
689
986
|
const buffer = Buffer.from(data, 'base64');
|
|
@@ -695,32 +992,104 @@ export class AgentBrowserService {
|
|
|
695
992
|
if (message.method === 'Page.frameNavigated') {
|
|
696
993
|
const frame = (message.params?.frame ?? null);
|
|
697
994
|
if (frame && !frame.parentId && frame.url) {
|
|
698
|
-
this.
|
|
699
|
-
|
|
995
|
+
const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
996
|
+
if (tab) {
|
|
997
|
+
tab.url = frame.url;
|
|
998
|
+
}
|
|
999
|
+
void this.refreshActivePageInfo().finally(() => {
|
|
700
1000
|
void this.broadcastStatus();
|
|
701
1001
|
});
|
|
702
1002
|
}
|
|
703
1003
|
return;
|
|
704
1004
|
}
|
|
705
1005
|
if (message.method === 'Page.loadEventFired') {
|
|
706
|
-
void this.
|
|
1006
|
+
void this.refreshActivePageInfo().finally(() => {
|
|
707
1007
|
void this.broadcastStatus();
|
|
708
1008
|
});
|
|
709
1009
|
}
|
|
710
1010
|
}
|
|
711
|
-
|
|
712
|
-
|
|
1011
|
+
// Events from the browser-level CDP connection: tab lifecycle.
|
|
1012
|
+
handleBrowserEvent(message) {
|
|
1013
|
+
const method = message.method;
|
|
1014
|
+
if (method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') {
|
|
1015
|
+
const info = message.params?.targetInfo;
|
|
1016
|
+
if (info) {
|
|
1017
|
+
this.upsertTab(info);
|
|
1018
|
+
}
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (method === 'Target.targetDestroyed') {
|
|
1022
|
+
const targetId = typeof message.params?.targetId === 'string' ? message.params.targetId : null;
|
|
1023
|
+
if (targetId) {
|
|
1024
|
+
this.removeTab(targetId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
upsertTab(info) {
|
|
1029
|
+
if (info.type !== 'page') {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const url = info.url ?? '';
|
|
1033
|
+
if (url.startsWith('devtools://')) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const prev = this.tabs.get(info.targetId);
|
|
1037
|
+
const isNew = !prev;
|
|
1038
|
+
const title = info.title ?? '';
|
|
1039
|
+
const changed = !prev || prev.url !== url || prev.title !== title;
|
|
1040
|
+
this.tabs.set(info.targetId, { id: info.targetId, url, title });
|
|
1041
|
+
if (isNew && this.ready && this.activeTargetId !== info.targetId) {
|
|
1042
|
+
// A page spawned a new tab/popup (target=_blank, window.open) — follow it
|
|
1043
|
+
// the way a normal browser does.
|
|
1044
|
+
void this.setActiveTab(info.targetId);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (changed) {
|
|
1048
|
+
void this.broadcastStatus();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
removeTab(targetId) {
|
|
1052
|
+
if (!this.tabs.has(targetId)) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const ids = [...this.tabs.keys()];
|
|
1056
|
+
const idx = ids.indexOf(targetId);
|
|
1057
|
+
this.tabs.delete(targetId);
|
|
1058
|
+
if (this.activeTargetId === targetId) {
|
|
1059
|
+
this.activeTargetId = null;
|
|
1060
|
+
this.disconnectActiveCdp();
|
|
1061
|
+
this.clearActiveFrame();
|
|
1062
|
+
this.broadcast({ type: 'clear' });
|
|
1063
|
+
const nextId = ids[idx + 1] ?? ids[idx - 1] ?? null;
|
|
1064
|
+
if (nextId && this.tabs.has(nextId)) {
|
|
1065
|
+
void this.setActiveTab(nextId);
|
|
1066
|
+
return; // setActiveTab broadcasts status
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
void this.broadcastStatus();
|
|
1070
|
+
}
|
|
1071
|
+
clearActiveFrame() {
|
|
1072
|
+
this.lastFrameBuffer = null;
|
|
1073
|
+
this.pendingFrames.clear();
|
|
1074
|
+
}
|
|
1075
|
+
async refreshActivePageInfo() {
|
|
1076
|
+
if (!this.activeCdp || !this.activeTargetId) {
|
|
713
1077
|
return;
|
|
714
1078
|
}
|
|
715
1079
|
try {
|
|
716
|
-
const result = await this.
|
|
1080
|
+
const result = await this.activeCdp.send('Runtime.evaluate', {
|
|
717
1081
|
expression: '({ title: document.title, url: location.href })',
|
|
718
1082
|
returnByValue: true,
|
|
719
1083
|
});
|
|
720
1084
|
const value = result.result?.value;
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1085
|
+
const tab = this.tabs.get(this.activeTargetId);
|
|
1086
|
+
if (value && tab) {
|
|
1087
|
+
if (typeof value.title === 'string') {
|
|
1088
|
+
tab.title = value.title;
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof value.url === 'string') {
|
|
1091
|
+
tab.url = value.url;
|
|
1092
|
+
}
|
|
724
1093
|
}
|
|
725
1094
|
}
|
|
726
1095
|
catch {
|
|
@@ -841,11 +1210,11 @@ export class AgentBrowserService {
|
|
|
841
1210
|
}
|
|
842
1211
|
}
|
|
843
1212
|
}
|
|
844
|
-
|
|
845
|
-
const cdp = this.
|
|
846
|
-
const socket = this.
|
|
847
|
-
this.
|
|
848
|
-
this.
|
|
1213
|
+
disconnectActiveCdp() {
|
|
1214
|
+
const cdp = this.activeCdp;
|
|
1215
|
+
const socket = this.activeCdpSocket;
|
|
1216
|
+
this.activeCdp = null;
|
|
1217
|
+
this.activeCdpSocket = null;
|
|
849
1218
|
this.screencastStarted = false;
|
|
850
1219
|
if (cdp) {
|
|
851
1220
|
cdp.close();
|
|
@@ -859,6 +1228,23 @@ export class AgentBrowserService {
|
|
|
859
1228
|
}
|
|
860
1229
|
}
|
|
861
1230
|
}
|
|
1231
|
+
closeBrowserCdp() {
|
|
1232
|
+
const cdp = this.browserCdp;
|
|
1233
|
+
const socket = this.browserCdpSocket;
|
|
1234
|
+
this.browserCdp = null;
|
|
1235
|
+
this.browserCdpSocket = null;
|
|
1236
|
+
if (cdp) {
|
|
1237
|
+
cdp.close();
|
|
1238
|
+
}
|
|
1239
|
+
else if (socket) {
|
|
1240
|
+
try {
|
|
1241
|
+
socket.close();
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// ignore
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
862
1248
|
openLogStream() {
|
|
863
1249
|
this.closeLogStream();
|
|
864
1250
|
const logPath = path.join(this.outputDir, 'deckide-browser.log');
|