cdpilot 0.3.0 → 0.4.1
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/bin/cdpilot.js +5 -0
- package/package.json +1 -1
- package/src/cdpilot.py +554 -42
package/bin/cdpilot.js
CHANGED
|
@@ -324,6 +324,11 @@ function showHelp() {
|
|
|
324
324
|
new-tab [url] Open new tab
|
|
325
325
|
close-tab [id] Close tab
|
|
326
326
|
|
|
327
|
+
STEALTH & CAPTCHA
|
|
328
|
+
stealth [on|off] Toggle anti-fingerprint patches (opt-in)
|
|
329
|
+
captcha-check Detect CAPTCHA on active page (JSON output)
|
|
330
|
+
captcha-wait [s] Pause until user solves CAPTCHA (default 300s)
|
|
331
|
+
|
|
327
332
|
PROJECTS
|
|
328
333
|
projects List all project browser instances
|
|
329
334
|
project-stop <id> Stop a specific project's browser
|
package/package.json
CHANGED
package/src/cdpilot.py
CHANGED
|
@@ -14,7 +14,7 @@ Environment:
|
|
|
14
14
|
CDPILOT_PROFILE Isolated browser profile directory
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
__version__ = "0.1
|
|
17
|
+
__version__ = "0.4.1"
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import json
|
|
@@ -221,8 +221,250 @@ window.__cdpilot_waitFor = function(selector, timeout) {
|
|
|
221
221
|
});
|
|
222
222
|
};
|
|
223
223
|
"""
|
|
224
|
+
# ─── Stealth & CAPTCHA Detection ──────────────────────────────────────────────
|
|
225
|
+
# Zero-dependency anti-fingerprinting layer. Patches common automation tells
|
|
226
|
+
# (navigator.webdriver, missing chrome.runtime, plugin list, WebGL vendor).
|
|
227
|
+
# Runs via Page.addScriptToEvaluateOnNewDocument BEFORE any page script.
|
|
228
|
+
# Notes:
|
|
229
|
+
# - Does NOT weaken web security (CSP, CORS, SOP intact).
|
|
230
|
+
# - Idempotent: guarded by window.__cdpilot_stealth flag.
|
|
231
|
+
# - Fails silently per-patch; one failure cannot break the page.
|
|
232
|
+
# - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
|
|
233
|
+
STEALTH_JS = r"""
|
|
234
|
+
(function() {
|
|
235
|
+
'use strict';
|
|
236
|
+
if (window.__cdpilot_stealth) return;
|
|
237
|
+
try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
|
|
238
|
+
|
|
239
|
+
// 1) navigator.webdriver — only patch if it's actually `true`.
|
|
240
|
+
// Vanilla Chrome (no --enable-automation) already returns `false` and the
|
|
241
|
+
// property exists on Navigator.prototype. Replacing the getter when it's
|
|
242
|
+
// already safe creates a worse fingerprint (returning `undefined` instead
|
|
243
|
+
// of `false` is itself a tell). Only mask when CDP/automation flagged it.
|
|
244
|
+
try {
|
|
245
|
+
var wdValue;
|
|
246
|
+
try { wdValue = navigator.webdriver; } catch (_) {}
|
|
247
|
+
if (wdValue === true) {
|
|
248
|
+
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
|
249
|
+
get: function() { return false; },
|
|
250
|
+
configurable: true
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {}
|
|
254
|
+
|
|
255
|
+
// 2) window.chrome — real Chrome exposes chrome.runtime even without extensions
|
|
256
|
+
try {
|
|
257
|
+
if (!window.chrome) {
|
|
258
|
+
window.chrome = {};
|
|
259
|
+
}
|
|
260
|
+
if (!window.chrome.runtime) {
|
|
261
|
+
window.chrome.runtime = {
|
|
262
|
+
OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
|
|
263
|
+
PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (typeof window.chrome.app === 'undefined') {
|
|
267
|
+
window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {}
|
|
270
|
+
|
|
271
|
+
// 3) Permissions.query — reconcile with Notification.permission (classic tell)
|
|
272
|
+
try {
|
|
273
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
274
|
+
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
|
275
|
+
navigator.permissions.query = function(p) {
|
|
276
|
+
if (p && p.name === 'notifications') {
|
|
277
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
278
|
+
}
|
|
279
|
+
return origQuery(p);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {}
|
|
283
|
+
|
|
284
|
+
// 4) navigator.plugins — empty array is a headless tell. Provide standard PDF set.
|
|
285
|
+
// Critical: must be a real PluginArray instance, not vanilla Array, or
|
|
286
|
+
// detection scripts catch it via `navigator.plugins instanceof PluginArray`.
|
|
287
|
+
try {
|
|
288
|
+
var PluginArrayProto = (window.PluginArray && window.PluginArray.prototype) || null;
|
|
289
|
+
var PluginProto = (window.Plugin && window.Plugin.prototype) || null;
|
|
290
|
+
var MimeTypeProto = (window.MimeType && window.MimeType.prototype) || null;
|
|
291
|
+
|
|
292
|
+
var makeMime = function(type, suffixes, plugin) {
|
|
293
|
+
var m = Object.create(MimeTypeProto || Object.prototype);
|
|
294
|
+
Object.defineProperties(m, {
|
|
295
|
+
type: { value: type, enumerable: true },
|
|
296
|
+
suffixes: { value: suffixes, enumerable: true },
|
|
297
|
+
description: { value: '', enumerable: true },
|
|
298
|
+
enabledPlugin: { value: plugin, enumerable: true }
|
|
299
|
+
});
|
|
300
|
+
return m;
|
|
301
|
+
};
|
|
302
|
+
var makePlugin = function(name, filename) {
|
|
303
|
+
var p = Object.create(PluginProto || Object.prototype);
|
|
304
|
+
var mime = makeMime('application/pdf', 'pdf', p);
|
|
305
|
+
Object.defineProperties(p, {
|
|
306
|
+
name: { value: name, enumerable: true },
|
|
307
|
+
filename: { value: filename, enumerable: true },
|
|
308
|
+
description: { value: 'Portable Document Format', enumerable: true },
|
|
309
|
+
length: { value: 1, enumerable: true },
|
|
310
|
+
'0': { value: mime, enumerable: true }
|
|
311
|
+
});
|
|
312
|
+
p.item = function(i) { return i === 0 ? mime : null; };
|
|
313
|
+
p.namedItem = function(n) { return n === mime.type ? mime : null; };
|
|
314
|
+
return p;
|
|
315
|
+
};
|
|
316
|
+
var pluginNames = [
|
|
317
|
+
['PDF Viewer', 'internal-pdf-viewer'],
|
|
318
|
+
['Chrome PDF Viewer', 'internal-pdf-viewer'],
|
|
319
|
+
['Chromium PDF Viewer', 'internal-pdf-viewer'],
|
|
320
|
+
['Microsoft Edge PDF Viewer', 'internal-pdf-viewer'],
|
|
321
|
+
['WebKit built-in PDF', 'internal-pdf-viewer'],
|
|
322
|
+
];
|
|
323
|
+
var plugins = Object.create(PluginArrayProto || Array.prototype);
|
|
324
|
+
for (var i = 0; i < pluginNames.length; i++) {
|
|
325
|
+
var p = makePlugin(pluginNames[i][0], pluginNames[i][1]);
|
|
326
|
+
Object.defineProperty(plugins, i, { value: p, enumerable: true });
|
|
327
|
+
Object.defineProperty(plugins, p.name, { value: p });
|
|
328
|
+
}
|
|
329
|
+
Object.defineProperty(plugins, 'length', { value: pluginNames.length });
|
|
330
|
+
plugins.item = function(i) { return plugins[i] || null; };
|
|
331
|
+
plugins.namedItem = function(n) {
|
|
332
|
+
for (var k = 0; k < plugins.length; k++) if (plugins[k].name === n) return plugins[k];
|
|
333
|
+
return null;
|
|
334
|
+
};
|
|
335
|
+
plugins.refresh = function() {};
|
|
336
|
+
Object.defineProperty(Navigator.prototype, 'plugins', {
|
|
337
|
+
get: function() { return plugins; },
|
|
338
|
+
configurable: true
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {}
|
|
341
|
+
|
|
342
|
+
// 4b) Worker stealth — workers have their own global scope, so navigator.webdriver
|
|
343
|
+
// patches above do NOT propagate. Fingerprint scripts that probe via Worker
|
|
344
|
+
// (e.g. fpscanner.WEBDRIVER) catch this inconsistency. Fix: wrap the Worker
|
|
345
|
+
// constructor to prepend a navigator.webdriver patch via blob URL. Limitations:
|
|
346
|
+
// module workers and same-origin script URLs are best-effort (importScripts
|
|
347
|
+
// doesn't support module workers; cross-origin URLs may bypass via direct fetch).
|
|
348
|
+
try {
|
|
349
|
+
var OrigWorker = window.Worker;
|
|
350
|
+
if (OrigWorker && !window.__cdpilot_worker_patched) {
|
|
351
|
+
var workerPatch = "(function(){try{Object.defineProperty(self.navigator||{},'webdriver',{get:function(){return undefined;},configurable:true});}catch(e){}})();";
|
|
352
|
+
var WrappedWorker = function(scriptURL, options) {
|
|
353
|
+
try {
|
|
354
|
+
var isModule = options && options.type === 'module';
|
|
355
|
+
if (typeof scriptURL === 'string' && !isModule) {
|
|
356
|
+
// Wrap script in a blob that applies the patch then importScripts the original.
|
|
357
|
+
var wrapped = workerPatch + "importScripts(" + JSON.stringify(String(scriptURL)) + ");";
|
|
358
|
+
var blob = new Blob([wrapped], { type: 'application/javascript' });
|
|
359
|
+
scriptURL = URL.createObjectURL(blob);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {}
|
|
362
|
+
return new OrigWorker(scriptURL, options);
|
|
363
|
+
};
|
|
364
|
+
WrappedWorker.prototype = OrigWorker.prototype;
|
|
365
|
+
Object.setPrototypeOf(WrappedWorker, OrigWorker);
|
|
366
|
+
window.Worker = WrappedWorker;
|
|
367
|
+
Object.defineProperty(window, '__cdpilot_worker_patched', { value: true });
|
|
368
|
+
}
|
|
369
|
+
} catch (e) {}
|
|
370
|
+
|
|
371
|
+
// 5) WebGL vendor/renderer — UNMASKED_* params reveal "SwiftShader" in headless.
|
|
372
|
+
// Override returns generic Intel values. Read-only spoof; does not affect rendering.
|
|
373
|
+
try {
|
|
374
|
+
var spoofParam = function(orig, parameter) {
|
|
375
|
+
if (parameter === 37445) return 'Intel Inc.'; // UNMASKED_VENDOR_WEBGL
|
|
376
|
+
if (parameter === 37446) return 'Intel Iris OpenGL Engine'; // UNMASKED_RENDERER_WEBGL
|
|
377
|
+
return orig.call(this, parameter);
|
|
378
|
+
};
|
|
379
|
+
if (window.WebGLRenderingContext) {
|
|
380
|
+
var gp1 = WebGLRenderingContext.prototype.getParameter;
|
|
381
|
+
WebGLRenderingContext.prototype.getParameter = function(p) { return spoofParam.call(this, gp1, p); };
|
|
382
|
+
}
|
|
383
|
+
if (window.WebGL2RenderingContext) {
|
|
384
|
+
var gp2 = WebGL2RenderingContext.prototype.getParameter;
|
|
385
|
+
WebGL2RenderingContext.prototype.getParameter = function(p) { return spoofParam.call(this, gp2, p); };
|
|
386
|
+
}
|
|
387
|
+
} catch (e) {}
|
|
388
|
+
|
|
389
|
+
// 6) navigator.hardwareConcurrency — 0 is a tell. Clamp to a sane default if missing.
|
|
390
|
+
try {
|
|
391
|
+
if (!navigator.hardwareConcurrency || navigator.hardwareConcurrency < 2) {
|
|
392
|
+
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
|
|
393
|
+
get: function() { return 8; },
|
|
394
|
+
configurable: true
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {}
|
|
398
|
+
})();
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
# ─── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
402
|
+
# Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
|
|
403
|
+
# Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
|
|
404
|
+
# Pure detection — no DOM mutation, no network, no user data leak.
|
|
405
|
+
CAPTCHA_DETECT_JS = r"""
|
|
406
|
+
(function() {
|
|
407
|
+
try {
|
|
408
|
+
var result = { detected: false, types: [], details: [] };
|
|
409
|
+
var add = function(type, provider, extra) {
|
|
410
|
+
result.detected = true;
|
|
411
|
+
if (result.types.indexOf(type) < 0) result.types.push(type);
|
|
412
|
+
var d = { type: type, provider: provider };
|
|
413
|
+
if (extra) for (var k in extra) d[k] = extra[k];
|
|
414
|
+
result.details.push(d);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Cloudflare Turnstile
|
|
418
|
+
var ts = document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]');
|
|
419
|
+
if (ts.length > 0) add('turnstile', 'Cloudflare', { count: ts.length });
|
|
420
|
+
|
|
421
|
+
// Cloudflare interstitial / managed challenge
|
|
422
|
+
var cfChallenge = document.querySelector('#challenge-form, #challenge-stage, #cf-challenge-running, #cf-please-wait');
|
|
423
|
+
var titleLower = (document.title || '').toLowerCase();
|
|
424
|
+
if (cfChallenge || titleLower.indexOf('just a moment') >= 0 || titleLower.indexOf('attention required') >= 0) {
|
|
425
|
+
add('cloudflare-challenge', 'Cloudflare');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// hCaptcha
|
|
429
|
+
var hc = document.querySelectorAll('iframe[src*="hcaptcha.com"]');
|
|
430
|
+
var hcDiv = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
|
|
431
|
+
if (hc.length > 0 || hcDiv) add('hcaptcha', 'hCaptcha');
|
|
432
|
+
|
|
433
|
+
// reCAPTCHA (v2 visible, v2 invisible, v3, enterprise)
|
|
434
|
+
var rc = document.querySelectorAll('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"]');
|
|
435
|
+
var rcDiv = document.querySelector('.g-recaptcha');
|
|
436
|
+
if (rc.length > 0 || (rcDiv && !hcDiv)) add('recaptcha', 'Google');
|
|
437
|
+
|
|
438
|
+
// DataDome
|
|
439
|
+
if (document.querySelector('#datadome-captcha, iframe[src*="captcha-delivery.com"]')) {
|
|
440
|
+
add('datadome', 'DataDome');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// PerimeterX / HUMAN
|
|
444
|
+
if (document.querySelector('#px-captcha, [class*="px-captcha"]')) {
|
|
445
|
+
add('perimeterx', 'HUMAN');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Arkose Labs (FunCaptcha)
|
|
449
|
+
if (document.querySelector('iframe[src*="arkoselabs.com"], iframe[src*="funcaptcha.com"], #FunCaptcha')) {
|
|
450
|
+
add('arkose', 'Arkose Labs');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// GeeTest
|
|
454
|
+
if (document.querySelector('.geetest_holder, .geetest_panel, [id^="geetest_"]')) {
|
|
455
|
+
add('geetest', 'GeeTest');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return JSON.stringify(result);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return JSON.stringify({ detected: false, error: String(e) });
|
|
461
|
+
}
|
|
462
|
+
})()
|
|
463
|
+
"""
|
|
464
|
+
|
|
224
465
|
PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
|
|
225
466
|
HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
|
|
467
|
+
STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
|
|
226
468
|
DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
|
|
227
469
|
SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
|
|
228
470
|
|
|
@@ -542,11 +784,21 @@ window.__cdpilot_glow_timeout = setTimeout(function() {
|
|
|
542
784
|
_glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
|
|
543
785
|
|
|
544
786
|
async def _control_start(ws_url):
|
|
545
|
-
"""Enable glow, input blocker,
|
|
787
|
+
"""Enable glow, input blocker, visual feedback, and (if enabled) stealth.
|
|
788
|
+
|
|
789
|
+
Stealth JS is registered via addScriptToEvaluateOnNewDocument so it runs
|
|
790
|
+
BEFORE any page script on the next navigation. It cannot un-fingerprint
|
|
791
|
+
the currently-loaded document — that's by design.
|
|
792
|
+
"""
|
|
546
793
|
global _glow_script_id
|
|
547
794
|
try:
|
|
548
|
-
#
|
|
549
|
-
|
|
795
|
+
# NOTE: addScriptToEvaluateOnNewDocument registered here is session-bound
|
|
796
|
+
# to the WS connection cdp_send opens — it dies when this call returns.
|
|
797
|
+
# That's fine for GLOW (also injected via Runtime.evaluate below) but
|
|
798
|
+
# NOT for stealth, which must run BEFORE page scripts. Stealth is
|
|
799
|
+
# therefore registered inside navigate_collect on the same WS as
|
|
800
|
+
# Page.navigate, so it survives long enough to apply.
|
|
801
|
+
cmds = [(898, "Page.enable", {})]
|
|
550
802
|
if _glow_script_id:
|
|
551
803
|
cmds.append((900, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
|
|
552
804
|
_glow_script_id = None
|
|
@@ -853,6 +1105,18 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
853
1105
|
await ws.send(json.dumps({"id": sid, "method": f"{domain}.enable", "params": {}}))
|
|
854
1106
|
sid += 1
|
|
855
1107
|
|
|
1108
|
+
# Register stealth script BEFORE navigate, on this same WS session.
|
|
1109
|
+
# addScriptToEvaluateOnNewDocument is session-bound: it persists only
|
|
1110
|
+
# as long as this connection lives — which is exactly the window we
|
|
1111
|
+
# need (until Page.loadEventFired below). Stealth must run before
|
|
1112
|
+
# any page script inspects navigator; addScript guarantees that.
|
|
1113
|
+
stealth_active = get_stealth_config()
|
|
1114
|
+
if stealth_active:
|
|
1115
|
+
await ws.send(json.dumps({
|
|
1116
|
+
"id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
|
|
1117
|
+
"params": {"source": STEALTH_JS}
|
|
1118
|
+
}))
|
|
1119
|
+
|
|
856
1120
|
# Navigate
|
|
857
1121
|
await ws.send(json.dumps({"id": 100, "method": "Page.navigate", "params": {"url": url}}))
|
|
858
1122
|
|
|
@@ -1187,6 +1451,25 @@ def get_headless_config():
|
|
|
1187
1451
|
pass
|
|
1188
1452
|
return False
|
|
1189
1453
|
|
|
1454
|
+
|
|
1455
|
+
def get_stealth_config():
|
|
1456
|
+
"""Return whether stealth fingerprint patches are enabled.
|
|
1457
|
+
|
|
1458
|
+
Default: False (opt-in) — preserves existing behavior, lets users enable
|
|
1459
|
+
only when needed. Some sites detect inconsistent fingerprints and
|
|
1460
|
+
block stealth-mode harder than they would block plain Chrome.
|
|
1461
|
+
"""
|
|
1462
|
+
env = os.environ.get('CDPILOT_STEALTH', '')
|
|
1463
|
+
if env:
|
|
1464
|
+
return env.lower() in ('1', 'true', 'yes', 'on')
|
|
1465
|
+
if os.path.exists(STEALTH_CONFIG_FILE):
|
|
1466
|
+
try:
|
|
1467
|
+
with open(STEALTH_CONFIG_FILE) as f:
|
|
1468
|
+
return bool(json.load(f).get('stealth', False))
|
|
1469
|
+
except (OSError, ValueError):
|
|
1470
|
+
pass
|
|
1471
|
+
return False
|
|
1472
|
+
|
|
1190
1473
|
# ─── Commands ───
|
|
1191
1474
|
|
|
1192
1475
|
def cmd_launch():
|
|
@@ -1220,6 +1503,15 @@ def cmd_launch():
|
|
|
1220
1503
|
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
1221
1504
|
print(f'Launching browser (isolated session, port {CDP_PORT}){proj_label}...')
|
|
1222
1505
|
|
|
1506
|
+
# ─── Stability fixes (2026-04-21) ───
|
|
1507
|
+
# Brave on Apple Silicon crashed with SIGTRAP/EXC_BREAKPOINT deterministically
|
|
1508
|
+
# at ~7 min uptime on ThreadPoolForegroundWorker. Two changes:
|
|
1509
|
+
# 1) `--disable-gpu-compositing` removed — conflicts with Metal accel on
|
|
1510
|
+
# M-series. Browser falls back to GPU-accel rendering which is the
|
|
1511
|
+
# Chromium default and matches what websites expect.
|
|
1512
|
+
# 2) `--disable-features=...` added — catches Brave-specific background
|
|
1513
|
+
# tasks that individual `--disable-brave-*` flags don't fully kill
|
|
1514
|
+
# (Rewards ad scanner, Sync heartbeat, News fetcher).
|
|
1223
1515
|
chrome_args = [
|
|
1224
1516
|
CHROME_BIN,
|
|
1225
1517
|
f'--remote-debugging-port={CDP_PORT}',
|
|
@@ -1238,6 +1530,14 @@ def cmd_launch():
|
|
|
1238
1530
|
'--disable-tor',
|
|
1239
1531
|
'--disable-ipfs',
|
|
1240
1532
|
'--disable-brave-extension',
|
|
1533
|
+
# Feature-level disables — catch background workers the
|
|
1534
|
+
# single-purpose flags above miss. Comma-joined string = one arg.
|
|
1535
|
+
'--disable-features=' + ','.join([
|
|
1536
|
+
'BraveRewards', 'BraveAds', 'BraveSync', 'BraveNewsToday',
|
|
1537
|
+
'BraveVPN', 'BraveWalletBubble', 'SpeedReader', 'Tor',
|
|
1538
|
+
'IPFSCompanion', 'Translate', 'OptimizationGuideModelDownloading',
|
|
1539
|
+
'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
|
|
1540
|
+
]),
|
|
1241
1541
|
# ─── Chromium performance flags ───
|
|
1242
1542
|
'--disable-background-networking',
|
|
1243
1543
|
'--disable-background-timer-throttling',
|
|
@@ -1259,7 +1559,9 @@ def cmd_launch():
|
|
|
1259
1559
|
'--safebrowsing-disable-auto-update',
|
|
1260
1560
|
'--password-store=basic',
|
|
1261
1561
|
# ─── GPU / rendering ───
|
|
1262
|
-
|
|
1562
|
+
# --disable-gpu-compositing REMOVED: caused EXC_BREAKPOINT on Apple
|
|
1563
|
+
# Silicon Brave 147+ after ~7min. Let the browser use its default
|
|
1564
|
+
# (Metal-accelerated) compositor.
|
|
1263
1565
|
'--disable-smooth-scrolling',
|
|
1264
1566
|
'--new-window', 'about:blank',
|
|
1265
1567
|
]
|
|
@@ -1315,6 +1617,15 @@ async def cmd_go(url):
|
|
|
1315
1617
|
ws, page = get_page_ws()
|
|
1316
1618
|
content, _ = await navigate_collect(ws, url)
|
|
1317
1619
|
print(content)
|
|
1620
|
+
# Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
|
|
1621
|
+
# Users can run `cdpilot captcha-wait` to pause for manual solve.
|
|
1622
|
+
try:
|
|
1623
|
+
info = await _detect_captcha(ws)
|
|
1624
|
+
if info.get("detected"):
|
|
1625
|
+
types = ",".join(info.get("types", [])) or "unknown"
|
|
1626
|
+
sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
|
|
1627
|
+
except Exception:
|
|
1628
|
+
pass
|
|
1318
1629
|
|
|
1319
1630
|
|
|
1320
1631
|
async def cmd_content():
|
|
@@ -1933,52 +2244,71 @@ def cmd_session_close(session_id=None):
|
|
|
1933
2244
|
|
|
1934
2245
|
|
|
1935
2246
|
def cmd_extensions():
|
|
1936
|
-
"""List installed extensions.
|
|
1937
|
-
ext_dir = os.path.join(PROFILE_DIR, "Default", "Extensions")
|
|
1938
|
-
if not os.path.isdir(ext_dir):
|
|
1939
|
-
print("No extensions installed.")
|
|
1940
|
-
return
|
|
2247
|
+
"""List installed extensions (packed via Chrome Web Store + dev mode unpacked).
|
|
1941
2248
|
|
|
1942
|
-
|
|
2249
|
+
Two sources are checked independently — neither absence should hide the other.
|
|
2250
|
+
Previously an early-return on missing Default/Extensions/ silently swallowed
|
|
2251
|
+
dev-mode entries written by `ext-install`.
|
|
2252
|
+
"""
|
|
2253
|
+
# 1) Packed (CRX) extensions live under Default/Extensions and are described
|
|
2254
|
+
# in Default/Preferences. Only present after the user installs from the
|
|
2255
|
+
# Chrome Web Store; ext-install does NOT write here.
|
|
2256
|
+
ext_dir = os.path.join(PROFILE_DIR, "Default", "Extensions")
|
|
2257
|
+
packed_ids = []
|
|
1943
2258
|
ext_names = {}
|
|
1944
|
-
if os.path.
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2259
|
+
if os.path.isdir(ext_dir):
|
|
2260
|
+
prefs_path = os.path.join(PROFILE_DIR, "Default", "Preferences")
|
|
2261
|
+
if os.path.exists(prefs_path):
|
|
2262
|
+
try:
|
|
2263
|
+
with open(prefs_path) as f:
|
|
2264
|
+
prefs = json.load(f)
|
|
2265
|
+
settings = prefs.get("extensions", {}).get("settings", {})
|
|
2266
|
+
for ext_id, info in settings.items():
|
|
2267
|
+
manifest = info.get("manifest", {})
|
|
2268
|
+
ext_names[ext_id] = {
|
|
2269
|
+
"name": manifest.get("name", ext_id),
|
|
2270
|
+
"version": manifest.get("version", "?"),
|
|
2271
|
+
"enabled": info.get("state", 1) == 1,
|
|
2272
|
+
}
|
|
2273
|
+
except Exception:
|
|
2274
|
+
pass
|
|
2275
|
+
packed_ids = [d for d in os.listdir(ext_dir) if not d.startswith(".")]
|
|
2276
|
+
|
|
2277
|
+
# 2) Dev-mode (unpacked) extensions registered by `ext-install` —
|
|
2278
|
+
# loaded into the browser via --load-extension on each launch.
|
|
2279
|
+
dev_exts = get_dev_extensions()
|
|
1959
2280
|
|
|
1960
|
-
|
|
1961
|
-
if not ext_ids:
|
|
2281
|
+
if not packed_ids and not dev_exts:
|
|
1962
2282
|
print("No extensions installed.")
|
|
1963
2283
|
return
|
|
1964
2284
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2285
|
+
if packed_ids:
|
|
2286
|
+
for ext_id in sorted(packed_ids):
|
|
2287
|
+
info = ext_names.get(ext_id, {})
|
|
2288
|
+
name = info.get("name", ext_id)
|
|
2289
|
+
version = info.get("version", "?")
|
|
2290
|
+
enabled = info.get("enabled", True)
|
|
2291
|
+
status = "✅" if enabled else "⏸️"
|
|
2292
|
+
print(f" {status} {name} (v{version})")
|
|
2293
|
+
print(f" ID: {ext_id}")
|
|
2294
|
+
print(f"\n{len(packed_ids)} packed extension{'s' if len(packed_ids) != 1 else ''}")
|
|
1973
2295
|
|
|
1974
|
-
print(f"\n{len(ext_ids)} extensions")
|
|
1975
|
-
|
|
1976
|
-
dev_exts = get_dev_extensions()
|
|
1977
2296
|
if dev_exts:
|
|
1978
|
-
|
|
2297
|
+
if packed_ids:
|
|
2298
|
+
print()
|
|
2299
|
+
print(f"Dev Mode Extensions ({len(dev_exts)}):")
|
|
1979
2300
|
for i, path in enumerate(dev_exts):
|
|
1980
2301
|
exists = '✅' if os.path.isdir(path) else '❌ (directory not found)'
|
|
1981
|
-
|
|
2302
|
+
# Try to read manifest for friendlier output
|
|
2303
|
+
label = os.path.basename(path.rstrip('/'))
|
|
2304
|
+
try:
|
|
2305
|
+
with open(os.path.join(path, 'manifest.json')) as f:
|
|
2306
|
+
mf = json.load(f)
|
|
2307
|
+
label = f"{mf.get('name', label)} (v{mf.get('version', '?')})"
|
|
2308
|
+
except Exception:
|
|
2309
|
+
pass
|
|
2310
|
+
print(f" {exists} [{i}] {label}")
|
|
2311
|
+
print(f" {path}")
|
|
1982
2312
|
|
|
1983
2313
|
|
|
1984
2314
|
def cmd_ext_install(source):
|
|
@@ -2297,6 +2627,183 @@ def cmd_headless(state=None):
|
|
|
2297
2627
|
print('Restart browser (stop → launch).')
|
|
2298
2628
|
|
|
2299
2629
|
|
|
2630
|
+
def cmd_health():
|
|
2631
|
+
"""Print JSON health summary of the cdpilot browser session.
|
|
2632
|
+
|
|
2633
|
+
Output keys:
|
|
2634
|
+
alive — bool, CDP /json/version reachable
|
|
2635
|
+
port — int, current CDP port
|
|
2636
|
+
project_id — str|null, project identifier (multi-instance)
|
|
2637
|
+
tabs — int, count of page targets (when alive)
|
|
2638
|
+
browser — str|null, version string from /json/version
|
|
2639
|
+
crashes_today — int, Brave crash dump count from macOS today
|
|
2640
|
+
stealth — bool, current stealth config
|
|
2641
|
+
uptime_warning — str|null, hint when browser is alive but very old
|
|
2642
|
+
|
|
2643
|
+
Exit codes: 0 = alive, 2 = down. Designed for shell watchdog loops:
|
|
2644
|
+
`until cdpilot health >/dev/null; do cdpilot launch; sleep 2; done`
|
|
2645
|
+
"""
|
|
2646
|
+
import datetime as _dt
|
|
2647
|
+
import glob as _glob
|
|
2648
|
+
|
|
2649
|
+
info = {
|
|
2650
|
+
'alive': False,
|
|
2651
|
+
'port': CDP_PORT,
|
|
2652
|
+
'project_id': PROJECT_ID,
|
|
2653
|
+
'tabs': 0,
|
|
2654
|
+
'browser': None,
|
|
2655
|
+
'crashes_today': 0,
|
|
2656
|
+
'stealth': get_stealth_config(),
|
|
2657
|
+
'uptime_warning': None,
|
|
2658
|
+
}
|
|
2659
|
+
ver = cdp_get('/json/version')
|
|
2660
|
+
if ver:
|
|
2661
|
+
info['alive'] = True
|
|
2662
|
+
info['browser'] = ver.get('Browser') or ver.get('browser') or ''
|
|
2663
|
+
targets = cdp_get('/json') or []
|
|
2664
|
+
info['tabs'] = sum(1 for t in targets if t.get('type') == 'page')
|
|
2665
|
+
|
|
2666
|
+
# Today's crash count from macOS DiagnosticReports (Brave only).
|
|
2667
|
+
if platform.system() == 'Darwin':
|
|
2668
|
+
today = _dt.date.today().strftime('%Y-%m-%d')
|
|
2669
|
+
pattern = os.path.expanduser(f'~/Library/Logs/DiagnosticReports/Brave Browser-{today}-*.ips')
|
|
2670
|
+
info['crashes_today'] = len(_glob.glob(pattern))
|
|
2671
|
+
if info['crashes_today'] >= 3:
|
|
2672
|
+
info['uptime_warning'] = f"{info['crashes_today']} Brave crashes today — consider `cdpilot stop` then relaunch"
|
|
2673
|
+
|
|
2674
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
2675
|
+
sys.exit(0 if info['alive'] else 2)
|
|
2676
|
+
|
|
2677
|
+
|
|
2678
|
+
def cmd_stealth(state=None):
|
|
2679
|
+
"""Toggle stealth fingerprint patches.
|
|
2680
|
+
|
|
2681
|
+
Usage:
|
|
2682
|
+
cdpilot stealth # show status
|
|
2683
|
+
cdpilot stealth on|off # toggle
|
|
2684
|
+
cdpilot stealth status # show status (alias)
|
|
2685
|
+
|
|
2686
|
+
Patches navigator.webdriver, chrome.runtime, plugins, WebGL vendor and
|
|
2687
|
+
permissions API to defeat the most common automation tells. Effect
|
|
2688
|
+
applies on the NEXT navigation (current pages keep their fingerprint).
|
|
2689
|
+
Zero new dependencies. Disabled by default.
|
|
2690
|
+
"""
|
|
2691
|
+
if state is None or state.lower() == 'status':
|
|
2692
|
+
current = get_stealth_config()
|
|
2693
|
+
print(f'Stealth: {"on" if current else "off"}')
|
|
2694
|
+
if current:
|
|
2695
|
+
print(' Patches: navigator.webdriver, chrome.runtime, plugins, WebGL vendor, permissions, hardwareConcurrency')
|
|
2696
|
+
return
|
|
2697
|
+
|
|
2698
|
+
s = state.lower()
|
|
2699
|
+
if s not in ('on', 'off', '1', '0', 'true', 'false', 'yes', 'no'):
|
|
2700
|
+
print(f"Invalid state: {state}. Use 'on', 'off', or 'status'.", file=sys.stderr)
|
|
2701
|
+
sys.exit(1)
|
|
2702
|
+
enabled = s in ('on', '1', 'true', 'yes')
|
|
2703
|
+
os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
|
|
2704
|
+
with open(STEALTH_CONFIG_FILE, 'w') as f:
|
|
2705
|
+
json.dump({'stealth': enabled}, f)
|
|
2706
|
+
print(f'Stealth: {"on" if enabled else "off"}')
|
|
2707
|
+
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
2708
|
+
|
|
2709
|
+
|
|
2710
|
+
async def _detect_captcha(ws_url):
|
|
2711
|
+
"""Run CAPTCHA_DETECT_JS in the active page, return parsed dict.
|
|
2712
|
+
|
|
2713
|
+
Returns: {"detected": bool, "types": [...], "details": [...], "error"?: str}
|
|
2714
|
+
Never raises — failures return {"detected": False, "error": "..."}.
|
|
2715
|
+
"""
|
|
2716
|
+
try:
|
|
2717
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
2718
|
+
"expression": CAPTCHA_DETECT_JS,
|
|
2719
|
+
"returnByValue": True,
|
|
2720
|
+
})], timeout=5)
|
|
2721
|
+
raw = r.get(1, {}).get("result", {}).get("value")
|
|
2722
|
+
if not raw:
|
|
2723
|
+
return {"detected": False}
|
|
2724
|
+
try:
|
|
2725
|
+
return json.loads(raw)
|
|
2726
|
+
except (ValueError, TypeError):
|
|
2727
|
+
return {"detected": False, "error": "parse_failed"}
|
|
2728
|
+
except Exception as e:
|
|
2729
|
+
return {"detected": False, "error": str(e)[:120]}
|
|
2730
|
+
|
|
2731
|
+
|
|
2732
|
+
async def cmd_captcha_check():
|
|
2733
|
+
"""One-shot CAPTCHA detection on the active page. Prints JSON.
|
|
2734
|
+
|
|
2735
|
+
Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
|
|
2736
|
+
Useful in scripts: `cdpilot captcha-check && do-stuff`.
|
|
2737
|
+
"""
|
|
2738
|
+
ws, _ = get_page_ws()
|
|
2739
|
+
info = await _detect_captcha(ws)
|
|
2740
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
2741
|
+
if info.get("error") and not info.get("detected"):
|
|
2742
|
+
sys.exit(1)
|
|
2743
|
+
sys.exit(3 if info.get("detected") else 0)
|
|
2744
|
+
|
|
2745
|
+
|
|
2746
|
+
async def cmd_captcha_wait(timeout_arg=None):
|
|
2747
|
+
"""Detect CAPTCHA and wait for the user to solve it.
|
|
2748
|
+
|
|
2749
|
+
Behavior:
|
|
2750
|
+
- Interactive (stdin is a TTY): print warning, block on Enter.
|
|
2751
|
+
- Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
|
|
2752
|
+
disappears or timeout expires; emit one JSON line per poll on
|
|
2753
|
+
state change so callers can stream progress.
|
|
2754
|
+
|
|
2755
|
+
Args:
|
|
2756
|
+
timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
|
|
2757
|
+
|
|
2758
|
+
Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
|
|
2759
|
+
"""
|
|
2760
|
+
try:
|
|
2761
|
+
timeout = int(timeout_arg) if timeout_arg else 300
|
|
2762
|
+
except (ValueError, TypeError):
|
|
2763
|
+
print(f"Invalid timeout: {timeout_arg}", file=sys.stderr)
|
|
2764
|
+
sys.exit(1)
|
|
2765
|
+
timeout = max(5, min(timeout, 1800))
|
|
2766
|
+
|
|
2767
|
+
ws, _ = get_page_ws()
|
|
2768
|
+
info = await _detect_captcha(ws)
|
|
2769
|
+
if not info.get("detected"):
|
|
2770
|
+
print(json.dumps({"detected": False, "status": "none"}, ensure_ascii=False))
|
|
2771
|
+
return
|
|
2772
|
+
|
|
2773
|
+
types = ",".join(info.get("types", [])) or "unknown"
|
|
2774
|
+
interactive = sys.stdin.isatty() and not IS_MCP_SESSION
|
|
2775
|
+
|
|
2776
|
+
if interactive:
|
|
2777
|
+
sys.stderr.write(f"\n⚠️ CAPTCHA tespit edildi: {types}\n")
|
|
2778
|
+
sys.stderr.write(" Tarayıcıda çözün, ardından Enter'a basın (Ctrl+C iptal)...\n")
|
|
2779
|
+
sys.stderr.flush()
|
|
2780
|
+
try:
|
|
2781
|
+
input()
|
|
2782
|
+
except (EOFError, KeyboardInterrupt):
|
|
2783
|
+
print(json.dumps({"detected": True, "status": "cancelled", "types": info.get("types", [])}, ensure_ascii=False))
|
|
2784
|
+
sys.exit(1)
|
|
2785
|
+
# Verify it's gone
|
|
2786
|
+
post = await _detect_captcha(ws)
|
|
2787
|
+
if post.get("detected"):
|
|
2788
|
+
print(json.dumps({"detected": True, "status": "still_present", "types": post.get("types", [])}, ensure_ascii=False))
|
|
2789
|
+
sys.exit(2)
|
|
2790
|
+
print(json.dumps({"detected": False, "status": "solved"}, ensure_ascii=False))
|
|
2791
|
+
return
|
|
2792
|
+
|
|
2793
|
+
# Non-interactive: poll until solved or timeout
|
|
2794
|
+
print(json.dumps({"detected": True, "status": "waiting", "types": info.get("types", []), "timeout": timeout}, ensure_ascii=False), flush=True)
|
|
2795
|
+
deadline = time.time() + timeout
|
|
2796
|
+
poll_interval = 2.0
|
|
2797
|
+
while time.time() < deadline:
|
|
2798
|
+
await asyncio.sleep(poll_interval)
|
|
2799
|
+
post = await _detect_captcha(ws)
|
|
2800
|
+
if not post.get("detected"):
|
|
2801
|
+
print(json.dumps({"detected": False, "status": "solved", "elapsed": round(timeout - (deadline - time.time()), 1)}, ensure_ascii=False), flush=True)
|
|
2802
|
+
return
|
|
2803
|
+
print(json.dumps({"detected": True, "status": "timeout", "types": info.get("types", []), "waited": timeout}, ensure_ascii=False), flush=True)
|
|
2804
|
+
sys.exit(2)
|
|
2805
|
+
|
|
2806
|
+
|
|
2300
2807
|
def _stop_browser_on_port(port):
|
|
2301
2808
|
"""Stop the browser process listening on the given port."""
|
|
2302
2809
|
import signal
|
|
@@ -4334,6 +4841,8 @@ if __name__ == "__main__":
|
|
|
4334
4841
|
'version': cmd_version,
|
|
4335
4842
|
'proxy': lambda: cmd_proxy(args[0] if args else None),
|
|
4336
4843
|
'headless': lambda: cmd_headless(args[0] if args else None),
|
|
4844
|
+
'stealth': lambda: cmd_stealth(args[0] if args else None),
|
|
4845
|
+
'health': cmd_health,
|
|
4337
4846
|
'session': cmd_session,
|
|
4338
4847
|
'sessions': cmd_sessions,
|
|
4339
4848
|
'session-close': lambda: cmd_session_close(args[0] if args else None),
|
|
@@ -4444,12 +4953,15 @@ if __name__ == "__main__":
|
|
|
4444
4953
|
'throttle': lambda: (require_args(1, 'throttle [slow3g|fast3g|offline|off|custom <down> <up> <lat>]'), None)[1] if not args else cmd_throttle(args[0], *args[1:]),
|
|
4445
4954
|
'geo': lambda: (require_args(1, 'geo [<lat> <lng>|istanbul|london|newyork|off]'), None)[1] if not args else cmd_geo(args[0], args[1] if len(args) > 1 else None, args[2] if len(args) > 2 else None),
|
|
4446
4955
|
'permission': lambda: (require_args(1, 'permission [grant|deny|reset] [<permission>]'), None)[1] if not args else cmd_permission(args[0], args[1] if len(args) > 1 else None),
|
|
4956
|
+
'captcha-check': cmd_captcha_check,
|
|
4957
|
+
'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
|
|
4447
4958
|
}
|
|
4448
4959
|
|
|
4449
4960
|
# Commands that do not require the visual indicator / input blocker
|
|
4450
4961
|
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
|
|
4451
4962
|
'dialog', 'download', 'throttle', 'permission', 'intercept',
|
|
4452
|
-
'batch', 'screenshot-diff', 'run'
|
|
4963
|
+
'batch', 'screenshot-diff', 'run',
|
|
4964
|
+
'captcha-check', 'captcha-wait'}
|
|
4453
4965
|
# Clean up idle sessions before running any command
|
|
4454
4966
|
_cleanup_idle_sessions()
|
|
4455
4967
|
|