cdpilot 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdpilot",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Zero-dependency browser automation from your terminal. One command, full control.",
5
5
  "bin": {
6
6
  "cdpilot": "./bin/cdpilot.js",
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.2"
17
+ __version__ = "0.4.0"
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, and visual feedback at command start."""
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
- # Remove previous persistent script if exists (prevent accumulation)
549
- cmds = []
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():
@@ -1315,6 +1598,15 @@ async def cmd_go(url):
1315
1598
  ws, page = get_page_ws()
1316
1599
  content, _ = await navigate_collect(ws, url)
1317
1600
  print(content)
1601
+ # Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
1602
+ # Users can run `cdpilot captcha-wait` to pause for manual solve.
1603
+ try:
1604
+ info = await _detect_captcha(ws)
1605
+ if info.get("detected"):
1606
+ types = ",".join(info.get("types", [])) or "unknown"
1607
+ sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
1608
+ except Exception:
1609
+ pass
1318
1610
 
1319
1611
 
1320
1612
  async def cmd_content():
@@ -2297,6 +2589,135 @@ def cmd_headless(state=None):
2297
2589
  print('Restart browser (stop → launch).')
2298
2590
 
2299
2591
 
2592
+ def cmd_stealth(state=None):
2593
+ """Toggle stealth fingerprint patches.
2594
+
2595
+ Usage:
2596
+ cdpilot stealth # show status
2597
+ cdpilot stealth on|off # toggle
2598
+ cdpilot stealth status # show status (alias)
2599
+
2600
+ Patches navigator.webdriver, chrome.runtime, plugins, WebGL vendor and
2601
+ permissions API to defeat the most common automation tells. Effect
2602
+ applies on the NEXT navigation (current pages keep their fingerprint).
2603
+ Zero new dependencies. Disabled by default.
2604
+ """
2605
+ if state is None or state.lower() == 'status':
2606
+ current = get_stealth_config()
2607
+ print(f'Stealth: {"on" if current else "off"}')
2608
+ if current:
2609
+ print(' Patches: navigator.webdriver, chrome.runtime, plugins, WebGL vendor, permissions, hardwareConcurrency')
2610
+ return
2611
+
2612
+ s = state.lower()
2613
+ if s not in ('on', 'off', '1', '0', 'true', 'false', 'yes', 'no'):
2614
+ print(f"Invalid state: {state}. Use 'on', 'off', or 'status'.", file=sys.stderr)
2615
+ sys.exit(1)
2616
+ enabled = s in ('on', '1', 'true', 'yes')
2617
+ os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
2618
+ with open(STEALTH_CONFIG_FILE, 'w') as f:
2619
+ json.dump({'stealth': enabled}, f)
2620
+ print(f'Stealth: {"on" if enabled else "off"}')
2621
+ print('Effect applies on next navigation (`cdpilot go <url>`).')
2622
+
2623
+
2624
+ async def _detect_captcha(ws_url):
2625
+ """Run CAPTCHA_DETECT_JS in the active page, return parsed dict.
2626
+
2627
+ Returns: {"detected": bool, "types": [...], "details": [...], "error"?: str}
2628
+ Never raises — failures return {"detected": False, "error": "..."}.
2629
+ """
2630
+ try:
2631
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
2632
+ "expression": CAPTCHA_DETECT_JS,
2633
+ "returnByValue": True,
2634
+ })], timeout=5)
2635
+ raw = r.get(1, {}).get("result", {}).get("value")
2636
+ if not raw:
2637
+ return {"detected": False}
2638
+ try:
2639
+ return json.loads(raw)
2640
+ except (ValueError, TypeError):
2641
+ return {"detected": False, "error": "parse_failed"}
2642
+ except Exception as e:
2643
+ return {"detected": False, "error": str(e)[:120]}
2644
+
2645
+
2646
+ async def cmd_captcha_check():
2647
+ """One-shot CAPTCHA detection on the active page. Prints JSON.
2648
+
2649
+ Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
2650
+ Useful in scripts: `cdpilot captcha-check && do-stuff`.
2651
+ """
2652
+ ws, _ = get_page_ws()
2653
+ info = await _detect_captcha(ws)
2654
+ print(json.dumps(info, ensure_ascii=False))
2655
+ if info.get("error") and not info.get("detected"):
2656
+ sys.exit(1)
2657
+ sys.exit(3 if info.get("detected") else 0)
2658
+
2659
+
2660
+ async def cmd_captcha_wait(timeout_arg=None):
2661
+ """Detect CAPTCHA and wait for the user to solve it.
2662
+
2663
+ Behavior:
2664
+ - Interactive (stdin is a TTY): print warning, block on Enter.
2665
+ - Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
2666
+ disappears or timeout expires; emit one JSON line per poll on
2667
+ state change so callers can stream progress.
2668
+
2669
+ Args:
2670
+ timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
2671
+
2672
+ Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
2673
+ """
2674
+ try:
2675
+ timeout = int(timeout_arg) if timeout_arg else 300
2676
+ except (ValueError, TypeError):
2677
+ print(f"Invalid timeout: {timeout_arg}", file=sys.stderr)
2678
+ sys.exit(1)
2679
+ timeout = max(5, min(timeout, 1800))
2680
+
2681
+ ws, _ = get_page_ws()
2682
+ info = await _detect_captcha(ws)
2683
+ if not info.get("detected"):
2684
+ print(json.dumps({"detected": False, "status": "none"}, ensure_ascii=False))
2685
+ return
2686
+
2687
+ types = ",".join(info.get("types", [])) or "unknown"
2688
+ interactive = sys.stdin.isatty() and not IS_MCP_SESSION
2689
+
2690
+ if interactive:
2691
+ sys.stderr.write(f"\n⚠️ CAPTCHA tespit edildi: {types}\n")
2692
+ sys.stderr.write(" Tarayıcıda çözün, ardından Enter'a basın (Ctrl+C iptal)...\n")
2693
+ sys.stderr.flush()
2694
+ try:
2695
+ input()
2696
+ except (EOFError, KeyboardInterrupt):
2697
+ print(json.dumps({"detected": True, "status": "cancelled", "types": info.get("types", [])}, ensure_ascii=False))
2698
+ sys.exit(1)
2699
+ # Verify it's gone
2700
+ post = await _detect_captcha(ws)
2701
+ if post.get("detected"):
2702
+ print(json.dumps({"detected": True, "status": "still_present", "types": post.get("types", [])}, ensure_ascii=False))
2703
+ sys.exit(2)
2704
+ print(json.dumps({"detected": False, "status": "solved"}, ensure_ascii=False))
2705
+ return
2706
+
2707
+ # Non-interactive: poll until solved or timeout
2708
+ print(json.dumps({"detected": True, "status": "waiting", "types": info.get("types", []), "timeout": timeout}, ensure_ascii=False), flush=True)
2709
+ deadline = time.time() + timeout
2710
+ poll_interval = 2.0
2711
+ while time.time() < deadline:
2712
+ await asyncio.sleep(poll_interval)
2713
+ post = await _detect_captcha(ws)
2714
+ if not post.get("detected"):
2715
+ print(json.dumps({"detected": False, "status": "solved", "elapsed": round(timeout - (deadline - time.time()), 1)}, ensure_ascii=False), flush=True)
2716
+ return
2717
+ print(json.dumps({"detected": True, "status": "timeout", "types": info.get("types", []), "waited": timeout}, ensure_ascii=False), flush=True)
2718
+ sys.exit(2)
2719
+
2720
+
2300
2721
  def _stop_browser_on_port(port):
2301
2722
  """Stop the browser process listening on the given port."""
2302
2723
  import signal
@@ -4334,6 +4755,7 @@ if __name__ == "__main__":
4334
4755
  'version': cmd_version,
4335
4756
  'proxy': lambda: cmd_proxy(args[0] if args else None),
4336
4757
  'headless': lambda: cmd_headless(args[0] if args else None),
4758
+ 'stealth': lambda: cmd_stealth(args[0] if args else None),
4337
4759
  'session': cmd_session,
4338
4760
  'sessions': cmd_sessions,
4339
4761
  'session-close': lambda: cmd_session_close(args[0] if args else None),
@@ -4444,12 +4866,15 @@ if __name__ == "__main__":
4444
4866
  '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
4867
  '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
4868
  '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),
4869
+ 'captcha-check': cmd_captcha_check,
4870
+ 'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
4447
4871
  }
4448
4872
 
4449
4873
  # Commands that do not require the visual indicator / input blocker
4450
4874
  NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
4451
4875
  'dialog', 'download', 'throttle', 'permission', 'intercept',
4452
- 'batch', 'screenshot-diff', 'run'}
4876
+ 'batch', 'screenshot-diff', 'run',
4877
+ 'captcha-check', 'captcha-wait'}
4453
4878
  # Clean up idle sessions before running any command
4454
4879
  _cleanup_idle_sessions()
4455
4880