cdpilot 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -4
- package/bin/cdpilot.js +77 -12
- package/package.json +10 -3
- package/src/cdpilot.py +2465 -94
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.
|
|
17
|
+
__version__ = "0.9.0"
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import atexit
|
|
@@ -236,7 +236,80 @@ window.__cdpilot_waitFor = function(selector, timeout) {
|
|
|
236
236
|
# - Idempotent: guarded by window.__cdpilot_stealth flag.
|
|
237
237
|
# - Fails silently per-patch; one failure cannot break the page.
|
|
238
238
|
# - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
|
|
239
|
-
|
|
239
|
+
#
|
|
240
|
+
# THREE-TIER MODEL (crawl4ai-style regular -> stealth -> undetected):
|
|
241
|
+
# - regular : NO fingerprint patch injected at all. Cleanest, fastest,
|
|
242
|
+
# relies only on --disable-blink-features=AutomationControlled
|
|
243
|
+
# set at launch. Lowest entropy -> fewest leaks. Default tier.
|
|
244
|
+
# - stealth : STEALTH_JS_LIGHT - webdriver + chrome.runtime + permissions
|
|
245
|
+
# reconcile ONLY. Deliberately OMITS the plugin-array spoof,
|
|
246
|
+
# which Stealth Bench V1 proved is itself a leak source
|
|
247
|
+
# (bot.sannysoft.com surfaced garbage plugin filenames like
|
|
248
|
+
# "rCJMteXy" from the synthetic PluginArray). Light tier is a
|
|
249
|
+
# strict subset of FULL.
|
|
250
|
+
# - undetected : STEALTH_JS_FULL - the full body (light subset + plugins +
|
|
251
|
+
# Worker patch + WebGL vendor + hardwareConcurrency). Highest
|
|
252
|
+
# plausibility on naive checks but highest entropy; pair with
|
|
253
|
+
# behavioral entropy/humanize for the hardest targets.
|
|
254
|
+
#
|
|
255
|
+
# STEALTH_JS retains the FULL body for backward compatibility - every existing
|
|
256
|
+
# reference (`cdpilot stealth on`, adaptive escalation) maps to undetected.
|
|
257
|
+
|
|
258
|
+
# Light tier: the SAFE subset. webdriver + chrome.runtime + permissions only.
|
|
259
|
+
# No plugin spoofing (bench leak), no WebGL vendor override, no Worker wrap.
|
|
260
|
+
STEALTH_JS_LIGHT = r"""
|
|
261
|
+
(function() {
|
|
262
|
+
'use strict';
|
|
263
|
+
if (window.__cdpilot_stealth) return;
|
|
264
|
+
try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
|
|
265
|
+
|
|
266
|
+
// 1) navigator.webdriver - only patch if it's actually `true`.
|
|
267
|
+
try {
|
|
268
|
+
var wdValue;
|
|
269
|
+
try { wdValue = navigator.webdriver; } catch (_) {}
|
|
270
|
+
if (wdValue === true) {
|
|
271
|
+
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
|
272
|
+
get: function() { return false; },
|
|
273
|
+
configurable: true
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {}
|
|
277
|
+
|
|
278
|
+
// 2) window.chrome - real Chrome exposes chrome.runtime even without extensions
|
|
279
|
+
try {
|
|
280
|
+
if (!window.chrome) {
|
|
281
|
+
window.chrome = {};
|
|
282
|
+
}
|
|
283
|
+
if (!window.chrome.runtime) {
|
|
284
|
+
window.chrome.runtime = {
|
|
285
|
+
OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
|
|
286
|
+
PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (typeof window.chrome.app === 'undefined') {
|
|
290
|
+
window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
|
|
291
|
+
}
|
|
292
|
+
} catch (e) {}
|
|
293
|
+
|
|
294
|
+
// 3) Permissions.query - reconcile with Notification.permission (classic tell)
|
|
295
|
+
try {
|
|
296
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
297
|
+
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
|
298
|
+
navigator.permissions.query = function(p) {
|
|
299
|
+
if (p && p.name === 'notifications') {
|
|
300
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
301
|
+
}
|
|
302
|
+
return origQuery(p);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
} catch (e) {}
|
|
306
|
+
})();
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
# Full tier (a.k.a. "undetected"): the complete body. Light subset + plugin
|
|
310
|
+
# array + Worker patch + WebGL vendor + hardwareConcurrency. STEALTH_JS is kept
|
|
311
|
+
# as an alias of FULL so every legacy reference still resolves.
|
|
312
|
+
STEALTH_JS_FULL = r"""
|
|
240
313
|
(function() {
|
|
241
314
|
'use strict';
|
|
242
315
|
if (window.__cdpilot_stealth) return;
|
|
@@ -404,6 +477,11 @@ STEALTH_JS = r"""
|
|
|
404
477
|
})();
|
|
405
478
|
"""
|
|
406
479
|
|
|
480
|
+
# Backward-compat alias: STEALTH_JS == the FULL (undetected) body. Every legacy
|
|
481
|
+
# reference (`cdpilot stealth on`, adaptive escalation, navigate inject) keeps
|
|
482
|
+
# resolving to the full patch set, exactly as before the three-tier split.
|
|
483
|
+
STEALTH_JS = STEALTH_JS_FULL
|
|
484
|
+
|
|
407
485
|
# ─── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
408
486
|
# Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
|
|
409
487
|
# Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
|
|
@@ -461,6 +539,14 @@ CAPTCHA_DETECT_JS = r"""
|
|
|
461
539
|
add('geetest', 'GeeTest');
|
|
462
540
|
}
|
|
463
541
|
|
|
542
|
+
// Amazon classic image CAPTCHA ("Type the characters you see in this image")
|
|
543
|
+
// Solvable locally with the optional amazoncaptcha library (image OCR).
|
|
544
|
+
var amzInput = document.querySelector('#captchacharacters');
|
|
545
|
+
var amzImg = document.querySelector('img[src*="opfcaptcha"], img[src*="captcha"][src*="images-na.ssl-images-amazon.com"], form[action*="/errors/validateCaptcha"] img');
|
|
546
|
+
if (amzInput && amzImg) {
|
|
547
|
+
add('amazon-classic', 'Amazon', { img_src: amzImg.src, input_id: amzInput.id || 'captchacharacters' });
|
|
548
|
+
}
|
|
549
|
+
|
|
464
550
|
return JSON.stringify(result);
|
|
465
551
|
} catch (e) {
|
|
466
552
|
return JSON.stringify({ detected: false, error: String(e) });
|
|
@@ -468,9 +554,127 @@ CAPTCHA_DETECT_JS = r"""
|
|
|
468
554
|
})()
|
|
469
555
|
"""
|
|
470
556
|
|
|
557
|
+
# ─── Progressive-Resilience Escalation Ladder ───
|
|
558
|
+
# Real sites stack defenses incrementally (sahibinden.com pattern: random
|
|
559
|
+
# captcha -> rate-limit -> login-wall -> SMS/OTP). CAPTCHA_DETECT_JS only sees
|
|
560
|
+
# the captcha rung. FRICTION_DETECT_JS detects the OTHER friction rungs purely
|
|
561
|
+
# from the DOM (read-only) and reports the HIGHEST one present. Bilingual
|
|
562
|
+
# (Turkish + English) keyword heuristics — TR sites are first-class.
|
|
563
|
+
#
|
|
564
|
+
# Ladder (low -> high):
|
|
565
|
+
# none normal page
|
|
566
|
+
# rate_limited HTTP 429 markers, "too many requests" / "çok fazla istek"
|
|
567
|
+
# soft_captcha (handled by CAPTCHA_DETECT_JS, merged in _detect_friction)
|
|
568
|
+
# login_wall login form gating the real content (sign in / giriş yap)
|
|
569
|
+
# otp_sms verification-code / OTP / SMS input
|
|
570
|
+
# hard_block access denied / banned / 403 forbidden, no content
|
|
571
|
+
FRICTION_DETECT_JS = r"""
|
|
572
|
+
(function() {
|
|
573
|
+
try {
|
|
574
|
+
var out = { level: 'none', signals: [], detail: '' };
|
|
575
|
+
var bodyText = (document.body ? (document.body.innerText || '') : '').toLowerCase();
|
|
576
|
+
var titleText = (document.title || '').toLowerCase();
|
|
577
|
+
var hay = titleText + ' \n ' + bodyText;
|
|
578
|
+
var sample = hay.slice(0, 6000);
|
|
579
|
+
var hasAny = function(words) {
|
|
580
|
+
for (var i = 0; i < words.length; i++) {
|
|
581
|
+
if (sample.indexOf(words[i]) >= 0) return words[i];
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// ── hard_block (highest) ──
|
|
587
|
+
var blockWords = [
|
|
588
|
+
'access denied', 'access to this page has been denied', 'you have been blocked',
|
|
589
|
+
'permanently banned', 'ip has been banned', '403 forbidden', 'forbidden',
|
|
590
|
+
'erişim engellendi', 'erişiminiz engellendi', 'engellendiniz', 'yasaklandı',
|
|
591
|
+
'reddedildi', 'banlandı'
|
|
592
|
+
];
|
|
593
|
+
var blockHit = hasAny(blockWords);
|
|
594
|
+
// Only treat as a hard block when the page has almost no real content
|
|
595
|
+
// (block pages are typically tiny). Avoids false positives on articles
|
|
596
|
+
// that merely mention these words.
|
|
597
|
+
var thinPage = bodyText.replace(/\s+/g, ' ').trim().length < 600;
|
|
598
|
+
|
|
599
|
+
// ── otp_sms ──
|
|
600
|
+
var otpWords = [
|
|
601
|
+
'verification code', 'one-time code', 'one time password', 'otp',
|
|
602
|
+
'sms code', 'code sent to your phone', 'enter the code',
|
|
603
|
+
'doğrulama kodu', 'onay kodu', 'tek kullanımlık', 'telefonunuza gönderilen',
|
|
604
|
+
'sms ile gönderilen', 'sms doğrulama', 'kodu girin'
|
|
605
|
+
];
|
|
606
|
+
var otpInput = document.querySelector(
|
|
607
|
+
'input[autocomplete="one-time-code"], input[name*="otp" i], input[id*="otp" i], ' +
|
|
608
|
+
'input[name*="code" i][maxlength], input[name*="sms" i], input[name*="verification" i], ' +
|
|
609
|
+
'input[id*="verification" i], input[name*="dogrulama" i], input[id*="dogrulama" i]'
|
|
610
|
+
);
|
|
611
|
+
var otpWordHit = hasAny(otpWords);
|
|
612
|
+
|
|
613
|
+
// ── login_wall ──
|
|
614
|
+
var pwInput = document.querySelector('input[type="password"]');
|
|
615
|
+
var loginForm = document.querySelector(
|
|
616
|
+
'form[action*="login" i], form[action*="signin" i], form[action*="giris" i], form[id*="login" i]'
|
|
617
|
+
);
|
|
618
|
+
var loginWords = [
|
|
619
|
+
'sign in', 'log in', 'login to continue', 'please sign in', 'you must be logged in',
|
|
620
|
+
'create an account to continue', 'sign in to continue',
|
|
621
|
+
'giriş yap', 'oturum aç', 'giriş yapın', 'üye girişi', 'devam etmek için giriş',
|
|
622
|
+
'giriş yapmalısınız', 'lütfen giriş yapın', 'hesabınıza giriş'
|
|
623
|
+
];
|
|
624
|
+
var loginWordHit = hasAny(loginWords);
|
|
625
|
+
|
|
626
|
+
// ── rate_limited ──
|
|
627
|
+
var rateWords = [
|
|
628
|
+
'too many requests', 'rate limit', 'rate limited', 'slow down',
|
|
629
|
+
'you are being rate limited', 'request limit', 'try again later',
|
|
630
|
+
'çok fazla istek', 'çok sık istek', 'lütfen yavaşlayın', 'yavaşlayın',
|
|
631
|
+
'istek sınırı', 'çok hızlı', 'daha sonra tekrar deneyin', 'güvenlik doğrulaması'
|
|
632
|
+
];
|
|
633
|
+
var rateWordHit = hasAny(rateWords);
|
|
634
|
+
// 429 surfaced into DOM (some sites print the status), or retry-after hint
|
|
635
|
+
var rate429 = /\b429\b/.test(sample) && (sample.indexOf('request') >= 0 || sample.indexOf('istek') >= 0 || rateWordHit);
|
|
636
|
+
var retryAfter = /retry[\s-]?after/.test(sample);
|
|
637
|
+
|
|
638
|
+
// Decide highest rung present.
|
|
639
|
+
if (blockHit && thinPage) {
|
|
640
|
+
out.level = 'hard_block';
|
|
641
|
+
out.signals.push('keyword:' + blockHit);
|
|
642
|
+
out.detail = 'Access denied / banned page with no real content.';
|
|
643
|
+
} else if (otpWordHit || otpInput) {
|
|
644
|
+
out.level = 'otp_sms';
|
|
645
|
+
if (otpWordHit) out.signals.push('keyword:' + otpWordHit);
|
|
646
|
+
if (otpInput) out.signals.push('input:otp');
|
|
647
|
+
out.detail = 'SMS/OTP verification prompt detected.';
|
|
648
|
+
} else if ((pwInput || loginForm) && loginWordHit) {
|
|
649
|
+
out.level = 'login_wall';
|
|
650
|
+
out.signals.push('keyword:' + loginWordHit);
|
|
651
|
+
if (pwInput) out.signals.push('input:password');
|
|
652
|
+
if (loginForm) out.signals.push('form:login');
|
|
653
|
+
out.detail = 'Login wall gating the page content.';
|
|
654
|
+
} else if (loginWordHit && loginForm) {
|
|
655
|
+
out.level = 'login_wall';
|
|
656
|
+
out.signals.push('keyword:' + loginWordHit);
|
|
657
|
+
out.signals.push('form:login');
|
|
658
|
+
out.detail = 'Login wall gating the page content.';
|
|
659
|
+
} else if (rateWordHit || rate429 || retryAfter) {
|
|
660
|
+
out.level = 'rate_limited';
|
|
661
|
+
if (rateWordHit) out.signals.push('keyword:' + rateWordHit);
|
|
662
|
+
if (rate429) out.signals.push('status:429');
|
|
663
|
+
if (retryAfter) out.signals.push('hint:retry-after');
|
|
664
|
+
out.detail = 'Rate-limit signal detected on page.';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return JSON.stringify(out);
|
|
668
|
+
} catch (e) {
|
|
669
|
+
return JSON.stringify({ level: 'none', signals: [], detail: '', error: String(e) });
|
|
670
|
+
}
|
|
671
|
+
})()
|
|
672
|
+
"""
|
|
673
|
+
|
|
471
674
|
PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
|
|
472
675
|
HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
|
|
473
676
|
STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
|
|
677
|
+
MODE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'mode.json')
|
|
474
678
|
BLOCK_CONFIG_FILE = os.path.join(PROFILE_DIR, 'block.json')
|
|
475
679
|
CAPTCHA_PROVIDERS_FILE = os.path.join(CDPILOT_HOME, 'captcha-providers.json')
|
|
476
680
|
CAPTCHA_AUTO_FILE = os.path.join(PROFILE_DIR, 'captcha-auto.json')
|
|
@@ -480,6 +684,10 @@ ADAPTIVE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'adaptive.json')
|
|
|
480
684
|
ENTROPY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'entropy.json')
|
|
481
685
|
DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
|
|
482
686
|
SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
|
|
687
|
+
# Tabs that cdpilot itself opened (via go / new-tab / smart-click). The smart
|
|
688
|
+
# `close` command only touches these — user tabs opened manually in the same
|
|
689
|
+
# isolated profile are left alone.
|
|
690
|
+
OWNED_TABS_FILE = os.path.join(PROFILE_DIR, 'owned-tabs.json')
|
|
483
691
|
COOKIES_DIR = os.path.join(CDPILOT_HOME, 'cookies')
|
|
484
692
|
COOKIES_AUTO_CONFIG_FILE = os.path.join(PROFILE_DIR, 'cookies-auto.json')
|
|
485
693
|
CF_CLEARANCE_COOKIES = frozenset({'cf_clearance', '__cf_bm', '_cfuvid'})
|
|
@@ -1030,6 +1238,9 @@ def _create_session_window():
|
|
|
1030
1238
|
"last_used": time.time(),
|
|
1031
1239
|
}
|
|
1032
1240
|
_save_sessions(sessions)
|
|
1241
|
+
# Freshly created by cdpilot -> owned. (The reuse branch above does not
|
|
1242
|
+
# mark, since that tab may have been opened by the user.)
|
|
1243
|
+
_mark_owned_tab(target_id)
|
|
1033
1244
|
|
|
1034
1245
|
return target_id
|
|
1035
1246
|
|
|
@@ -1405,11 +1616,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
1405
1616
|
# as long as this connection lives — which is exactly the window we
|
|
1406
1617
|
# need (until Page.loadEventFired below). Stealth must run before
|
|
1407
1618
|
# any page script inspects navigator; addScript guarantees that.
|
|
1408
|
-
|
|
1409
|
-
|
|
1619
|
+
#
|
|
1620
|
+
# Three-tier selection: regular -> inject nothing, stealth -> LIGHT,
|
|
1621
|
+
# undetected -> FULL. get_mode_config() folds in the legacy stealth
|
|
1622
|
+
# toggle and the CDPILOT_STEALTH env var (set during adaptive
|
|
1623
|
+
# escalation) so existing behavior is preserved.
|
|
1624
|
+
stealth_source = stealth_js_for_tier(get_mode_config())
|
|
1625
|
+
if stealth_source:
|
|
1410
1626
|
await ws.send(json.dumps({
|
|
1411
1627
|
"id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
|
|
1412
|
-
"params": {"source":
|
|
1628
|
+
"params": {"source": stealth_source}
|
|
1413
1629
|
}))
|
|
1414
1630
|
|
|
1415
1631
|
# Apply request blocking BEFORE navigate, on this same WS session.
|
|
@@ -1959,6 +2175,105 @@ def get_headless_config():
|
|
|
1959
2175
|
return False
|
|
1960
2176
|
|
|
1961
2177
|
|
|
2178
|
+
# ─── Three-tier stealth mode (crawl4ai-style escalation) ──────────────────────
|
|
2179
|
+
# Tiers, weakest fingerprint footprint -> strongest spoof:
|
|
2180
|
+
# regular -> inject NOTHING (cleanest, default)
|
|
2181
|
+
# stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
|
|
2182
|
+
# undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker)
|
|
2183
|
+
MODE_TIERS = ('regular', 'stealth', 'undetected')
|
|
2184
|
+
DEFAULT_MODE_TIER = 'regular'
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
def get_mode_config():
|
|
2188
|
+
"""Return the active stealth tier as a string in MODE_TIERS.
|
|
2189
|
+
|
|
2190
|
+
Resolution order: CDPILOT_MODE env -> mode.json -> legacy stealth on/off
|
|
2191
|
+
-> DEFAULT_MODE_TIER. Legacy `cdpilot stealth on` (no mode.json present)
|
|
2192
|
+
maps to 'undetected' so the old binary toggle still produces the full
|
|
2193
|
+
patch set; `stealth off` (or absent) keeps 'regular'.
|
|
2194
|
+
"""
|
|
2195
|
+
env = os.environ.get('CDPILOT_MODE', '').strip().lower()
|
|
2196
|
+
if env in MODE_TIERS:
|
|
2197
|
+
return env
|
|
2198
|
+
if os.path.exists(MODE_CONFIG_FILE):
|
|
2199
|
+
try:
|
|
2200
|
+
with open(MODE_CONFIG_FILE) as f:
|
|
2201
|
+
tier = json.load(f).get('tier', DEFAULT_MODE_TIER)
|
|
2202
|
+
if tier in MODE_TIERS:
|
|
2203
|
+
return tier
|
|
2204
|
+
except (OSError, ValueError):
|
|
2205
|
+
pass
|
|
2206
|
+
# Backwards-compat: no explicit mode set — honor the legacy stealth toggle.
|
|
2207
|
+
# stealth on -> undetected (full), stealth off -> regular (clean).
|
|
2208
|
+
return 'undetected' if get_stealth_config() else DEFAULT_MODE_TIER
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
def set_mode_config(tier):
|
|
2212
|
+
"""Persist the active tier to mode.json. Returns the normalized tier."""
|
|
2213
|
+
tier = (tier or '').strip().lower()
|
|
2214
|
+
if tier not in MODE_TIERS:
|
|
2215
|
+
raise ValueError(f"invalid tier: {tier!r}")
|
|
2216
|
+
_atomic_write_json(MODE_CONFIG_FILE, {'tier': tier})
|
|
2217
|
+
return tier
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def stealth_js_for_tier(tier):
|
|
2221
|
+
"""Return the fingerprint patch JS for a tier, or None for 'regular'.
|
|
2222
|
+
|
|
2223
|
+
regular -> None (inject nothing)
|
|
2224
|
+
stealth -> STEALTH_JS_LIGHT
|
|
2225
|
+
undetected -> STEALTH_JS_FULL
|
|
2226
|
+
"""
|
|
2227
|
+
if tier == 'stealth':
|
|
2228
|
+
return STEALTH_JS_LIGHT
|
|
2229
|
+
if tier == 'undetected':
|
|
2230
|
+
return STEALTH_JS_FULL
|
|
2231
|
+
return None
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def cmd_mode(tier=None):
|
|
2235
|
+
"""Get or set the three-tier stealth mode (regular | stealth | undetected).
|
|
2236
|
+
|
|
2237
|
+
Usage:
|
|
2238
|
+
cdpilot mode # show current tier + what it injects
|
|
2239
|
+
cdpilot mode regular # no fingerprint patch (cleanest, fastest)
|
|
2240
|
+
cdpilot mode stealth # light patch: webdriver/chrome.runtime/perms
|
|
2241
|
+
cdpilot mode undetected # full patch: + plugins + WebGL + Worker
|
|
2242
|
+
|
|
2243
|
+
Default: regular. Stealth Bench V1 found that the full patch set ALONE
|
|
2244
|
+
lowered scores (-6.3p) because the synthetic plugin array leaks, while
|
|
2245
|
+
light host-learning gained (+6.3p). The 'stealth' tier omits plugin
|
|
2246
|
+
spoofing for this reason. Escalate to 'undetected' only for hard targets.
|
|
2247
|
+
Effect applies on the NEXT navigation. Env override: CDPILOT_MODE=<tier>.
|
|
2248
|
+
"""
|
|
2249
|
+
if tier is None or tier.strip().lower() in ('', 'status'):
|
|
2250
|
+
current = get_mode_config()
|
|
2251
|
+
patch = {
|
|
2252
|
+
'regular': 'none (no fingerprint patch — cleanest)',
|
|
2253
|
+
'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
|
|
2254
|
+
'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)',
|
|
2255
|
+
}[current]
|
|
2256
|
+
print(f'Mode: {current}')
|
|
2257
|
+
print(f' Injects: {patch}')
|
|
2258
|
+
return
|
|
2259
|
+
t = tier.strip().lower()
|
|
2260
|
+
if t not in MODE_TIERS:
|
|
2261
|
+
print(f"Invalid tier: {tier}. Use one of: {', '.join(MODE_TIERS)}.", file=sys.stderr)
|
|
2262
|
+
sys.exit(1)
|
|
2263
|
+
set_mode_config(t)
|
|
2264
|
+
# Keep the legacy stealth toggle coherent so `stealth status` and any code
|
|
2265
|
+
# still reading get_stealth_config() agree with the tier:
|
|
2266
|
+
# regular -> stealth off
|
|
2267
|
+
# stealth -> stealth on (a patch IS injected, just the light one)
|
|
2268
|
+
# undetected-> stealth on
|
|
2269
|
+
try:
|
|
2270
|
+
_atomic_write_json(STEALTH_CONFIG_FILE, {'stealth': t != 'regular'})
|
|
2271
|
+
except Exception:
|
|
2272
|
+
pass
|
|
2273
|
+
print(f'Mode: {t}')
|
|
2274
|
+
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
2275
|
+
|
|
2276
|
+
|
|
1962
2277
|
def get_stealth_config():
|
|
1963
2278
|
"""Return whether stealth fingerprint patches are enabled.
|
|
1964
2279
|
|
|
@@ -2012,6 +2327,31 @@ def _sanitize_profile_state(profile_dir):
|
|
|
2012
2327
|
|
|
2013
2328
|
# ─── Commands ───
|
|
2014
2329
|
|
|
2330
|
+
def _minimize_browser_window():
|
|
2331
|
+
"""Minimize the browser window via CDP (Browser.setWindowBounds).
|
|
2332
|
+
|
|
2333
|
+
Reliable on macOS where negative --window-position is clamped back on
|
|
2334
|
+
screen. Uses cdpilot's own async cdp_send over the browser-level WS —
|
|
2335
|
+
no new dependency. Best-effort: any failure is swallowed by the caller.
|
|
2336
|
+
"""
|
|
2337
|
+
ver = cdp_get('/json/version') or {}
|
|
2338
|
+
ws_url = ver.get('webSocketDebuggerUrl')
|
|
2339
|
+
if not ws_url:
|
|
2340
|
+
return
|
|
2341
|
+
|
|
2342
|
+
async def _do():
|
|
2343
|
+
r = await cdp_send(ws_url, [(1, "Browser.getWindowForTarget", {})])
|
|
2344
|
+
win_id = ((r.get(1) or {}).get("result") or {}).get("windowId")
|
|
2345
|
+
if win_id is None:
|
|
2346
|
+
win_id = 1
|
|
2347
|
+
await cdp_send(ws_url, [(2, "Browser.setWindowBounds", {
|
|
2348
|
+
"windowId": win_id,
|
|
2349
|
+
"bounds": {"windowState": "minimized"},
|
|
2350
|
+
})])
|
|
2351
|
+
|
|
2352
|
+
asyncio.run(_do())
|
|
2353
|
+
|
|
2354
|
+
|
|
2015
2355
|
def cmd_launch():
|
|
2016
2356
|
"""Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
|
|
2017
2357
|
global CHROME_BIN, CDP_PORT, CDP_BASE
|
|
@@ -2098,6 +2438,12 @@ def cmd_launch():
|
|
|
2098
2438
|
'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
|
|
2099
2439
|
'PasswordManagerOnboarding', 'AutofillServerCommunication',
|
|
2100
2440
|
]),
|
|
2441
|
+
# Anti-bot leak fix (2026-05-31): Chrome's blink runtime exposes an
|
|
2442
|
+
# "automation controlled" flag when --remote-debugging-port is set;
|
|
2443
|
+
# Patchright/nodriver disable this. Even though navigator.webdriver
|
|
2444
|
+
# already reads false on vanilla Brave/Chrome, deeper detectors
|
|
2445
|
+
# (Cloudflare/Datadome behavioral) probe the blink flag directly.
|
|
2446
|
+
'--disable-blink-features=AutomationControlled',
|
|
2101
2447
|
# ─── Chromium performance flags ───
|
|
2102
2448
|
'--disable-background-networking',
|
|
2103
2449
|
'--disable-background-timer-throttling',
|
|
@@ -2128,6 +2474,17 @@ def cmd_launch():
|
|
|
2128
2474
|
'--new-window', 'about:blank',
|
|
2129
2475
|
]
|
|
2130
2476
|
|
|
2477
|
+
# Off-screen window placement (opt-in). Keeps the browser HEADED (anti-bot
|
|
2478
|
+
# detection still sees a real window / GPU) but parks it far off the visible
|
|
2479
|
+
# desktop so it never steals focus or pops in front of the user — ideal for
|
|
2480
|
+
# long bench runs on a workstation. CDPILOT_OFFSCREEN=1 or a custom
|
|
2481
|
+
# CDPILOT_WINDOW_POSITION="x,y". No effect on headless.
|
|
2482
|
+
_win_pos = os.environ.get('CDPILOT_WINDOW_POSITION', '')
|
|
2483
|
+
if not _win_pos and os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
|
|
2484
|
+
_win_pos = '-3000,-3000'
|
|
2485
|
+
if _win_pos:
|
|
2486
|
+
chrome_args.append(f'--window-position={_win_pos}')
|
|
2487
|
+
|
|
2131
2488
|
# Dev extensions
|
|
2132
2489
|
dev_exts = get_dev_extensions()
|
|
2133
2490
|
valid_exts = [p for p in dev_exts if os.path.isdir(p)]
|
|
@@ -2154,6 +2511,16 @@ def cmd_launch():
|
|
|
2154
2511
|
if cdp_get('/json/version'):
|
|
2155
2512
|
if PROJECT_ID:
|
|
2156
2513
|
_register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
|
|
2514
|
+
# Off-screen / minimized placement (opt-in). Keeps the browser
|
|
2515
|
+
# HEADED (anti-bot still sees a real GPU-backed window) but parks it
|
|
2516
|
+
# out of the way so long bench runs never steal focus or pop in
|
|
2517
|
+
# front of the user. macOS clamps negative --window-position, so we
|
|
2518
|
+
# also minimize via CDP (Browser.setWindowBounds) which is reliable.
|
|
2519
|
+
if os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
|
|
2520
|
+
try:
|
|
2521
|
+
_minimize_browser_window()
|
|
2522
|
+
except Exception:
|
|
2523
|
+
pass
|
|
2157
2524
|
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
2158
2525
|
print(f'CDP ready! (port {CDP_PORT}){proj_label}')
|
|
2159
2526
|
return
|
|
@@ -2177,6 +2544,9 @@ async def cmd_go(url):
|
|
|
2177
2544
|
cmd_launch()
|
|
2178
2545
|
|
|
2179
2546
|
ws, page = get_page_ws()
|
|
2547
|
+
# Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
|
|
2548
|
+
if isinstance(page, dict):
|
|
2549
|
+
_mark_owned_tab(page.get("id"))
|
|
2180
2550
|
|
|
2181
2551
|
try:
|
|
2182
2552
|
from urllib.parse import urlparse
|
|
@@ -2188,12 +2558,20 @@ async def cmd_go(url):
|
|
|
2188
2558
|
adaptive_stealth = False
|
|
2189
2559
|
is_known_hostile = _adaptive_host_requires_stealth(url)
|
|
2190
2560
|
|
|
2191
|
-
# Adaptive mode: if
|
|
2192
|
-
#
|
|
2193
|
-
|
|
2194
|
-
|
|
2561
|
+
# Adaptive mode (tier-aware): if we learned a tier for this host, start the
|
|
2562
|
+
# navigation at that tier via CDPILOT_MODE. Falls back to the legacy
|
|
2563
|
+
# stealth_hosts list (-> 'stealth' tier) for hosts learned before tiers
|
|
2564
|
+
# existed. CDPILOT_MODE takes precedence in get_mode_config().
|
|
2565
|
+
learned_tier = _adaptive_host_tier(url)
|
|
2566
|
+
if learned_tier and learned_tier != 'regular' and get_mode_config() == 'regular':
|
|
2567
|
+
os.environ['CDPILOT_MODE'] = learned_tier
|
|
2195
2568
|
adaptive_stealth = True
|
|
2196
|
-
sys.stderr.write(f"🛡️ Adaptive:
|
|
2569
|
+
sys.stderr.write(f"🛡️ Adaptive: starting at learned tier '{learned_tier}' for known-hostile host\n")
|
|
2570
|
+
elif is_known_hostile and get_mode_config() == 'regular':
|
|
2571
|
+
# Legacy stealth_hosts entry with no tier learned yet — start at light.
|
|
2572
|
+
os.environ['CDPILOT_MODE'] = 'stealth'
|
|
2573
|
+
adaptive_stealth = True
|
|
2574
|
+
sys.stderr.write(f"🛡️ Adaptive: enabling stealth tier for known-hostile host\n")
|
|
2197
2575
|
|
|
2198
2576
|
# Fix 1: Per-task context isolation (opt-in via CDPILOT_ADAPTIVE_FRESH_CONTEXT=1) — spawn a fresh BrowserContext when
|
|
2199
2577
|
# adaptive is on and we know this host is hostile. Prevents cross-task
|
|
@@ -2231,6 +2609,44 @@ async def cmd_go(url):
|
|
|
2231
2609
|
except Exception:
|
|
2232
2610
|
pass
|
|
2233
2611
|
|
|
2612
|
+
# Progressive-resilience friction probe (non-captcha rungs).
|
|
2613
|
+
# rate_limited -> exponential backoff + re-nav (bounded). login/otp/hard
|
|
2614
|
+
# -> stderr handoff warning only (NO autonomous bypass — ethics line).
|
|
2615
|
+
# The captcha rung is left to the dedicated probe below.
|
|
2616
|
+
try:
|
|
2617
|
+
fr = await _detect_friction(active_ws)
|
|
2618
|
+
fr_level = fr.get('level', 'none')
|
|
2619
|
+
if fr_level == 'rate_limited' and _friction_backoff_enabled():
|
|
2620
|
+
attempt = 0
|
|
2621
|
+
while fr_level == 'rate_limited' and attempt < _friction_max_retry():
|
|
2622
|
+
wait_s = _friction_backoff_seconds(attempt)
|
|
2623
|
+
sys.stderr.write(
|
|
2624
|
+
f"⏳ Rate-limit tespit edildi ({','.join(fr.get('signals', [])) or '?'}); "
|
|
2625
|
+
f"{wait_s}s backoff, retry {attempt + 1}/{_friction_max_retry()}...\n")
|
|
2626
|
+
if expected_host:
|
|
2627
|
+
_adaptive_remember_host(expected_host)
|
|
2628
|
+
await asyncio.sleep(wait_s)
|
|
2629
|
+
await navigate_collect(active_ws, url)
|
|
2630
|
+
fr = await _detect_friction(active_ws)
|
|
2631
|
+
fr_level = fr.get('level', 'none')
|
|
2632
|
+
attempt += 1
|
|
2633
|
+
if fr_level == 'rate_limited':
|
|
2634
|
+
sys.stderr.write("⚠️ Rate-limit backoff sonrası hâlâ sınırlı. Daha sonra deneyin.\n")
|
|
2635
|
+
else:
|
|
2636
|
+
sys.stderr.write("✅ Rate-limit temizlendi.\n")
|
|
2637
|
+
elif fr_level in ('login_wall', 'otp_sms', 'hard_block'):
|
|
2638
|
+
act = _friction_action(fr_level)
|
|
2639
|
+
payload = {'action': act['action'], 'url': url, 'level': fr_level,
|
|
2640
|
+
'signals': fr.get('signals', [])}
|
|
2641
|
+
if act.get('backoff_suggested'):
|
|
2642
|
+
payload['backoff_suggested'] = True
|
|
2643
|
+
# Human handoff: warn + emit JSON line, but DO NOT block the
|
|
2644
|
+
# navigation (caller may still want the partial content).
|
|
2645
|
+
sys.stderr.write(f"{act['message']}\n")
|
|
2646
|
+
sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
2647
|
+
except Exception:
|
|
2648
|
+
pass
|
|
2649
|
+
|
|
2234
2650
|
# Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
|
|
2235
2651
|
try:
|
|
2236
2652
|
info = await _detect_captcha(active_ws)
|
|
@@ -2238,20 +2654,26 @@ async def cmd_go(url):
|
|
|
2238
2654
|
types = ",".join(info.get("types", [])) or "unknown"
|
|
2239
2655
|
sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
|
|
2240
2656
|
|
|
2241
|
-
# Adaptive escalation:
|
|
2242
|
-
#
|
|
2657
|
+
# Adaptive escalation (tier-aware): bump the host one tier up
|
|
2658
|
+
# (regular -> stealth -> undetected), remember it, and re-nav
|
|
2659
|
+
# once at the new tier. Re-nav is gated to once-per-call.
|
|
2243
2660
|
if cfg['enabled']:
|
|
2661
|
+
current_tier = get_mode_config()
|
|
2662
|
+
next_tier = _escalate_tier(current_tier)
|
|
2244
2663
|
if expected_host:
|
|
2245
2664
|
_adaptive_remember_host(expected_host)
|
|
2246
|
-
|
|
2665
|
+
_adaptive_remember_host_tier(expected_host, next_tier)
|
|
2666
|
+
sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} at tier '{next_tier}'\n")
|
|
2247
2667
|
captcha_types = info.get('types', [])
|
|
2248
2668
|
_adaptive_remember_host_entropy(expected_host, captcha_types)
|
|
2249
2669
|
needs_entropy = any(CAPTCHA_ENTROPY_REQUIRED.get(t, True) for t in captcha_types)
|
|
2250
2670
|
if needs_entropy:
|
|
2251
2671
|
sys.stderr.write(f"🧬 Adaptive: per-host entropy enabled for {expected_host} ({','.join(captcha_types)})\n")
|
|
2252
|
-
if
|
|
2253
|
-
|
|
2254
|
-
|
|
2672
|
+
# Only retry if escalation actually changes the tier (not
|
|
2673
|
+
# already at the undetected ceiling for this navigation).
|
|
2674
|
+
if next_tier != current_tier:
|
|
2675
|
+
sys.stderr.write(f"🛡️ Adaptive: retrying once at tier '{next_tier}'...\n")
|
|
2676
|
+
os.environ['CDPILOT_MODE'] = next_tier
|
|
2255
2677
|
# Fix 3: Idempotent re-nav — skip if already on target.
|
|
2256
2678
|
current_host = await _adaptive_current_host(active_ws)
|
|
2257
2679
|
norm = lambda h: h[4:] if h.startswith("www.") else h
|
|
@@ -2259,12 +2681,12 @@ async def cmd_go(url):
|
|
|
2259
2681
|
sys.stderr.write("🛡️ Adaptive: already on target host, skip re-nav\n")
|
|
2260
2682
|
else:
|
|
2261
2683
|
await navigate_collect(active_ws, url)
|
|
2262
|
-
# Re-probe after
|
|
2684
|
+
# Re-probe after escalated re-nav
|
|
2263
2685
|
info2 = await _detect_captcha(active_ws)
|
|
2264
2686
|
if info2.get("detected"):
|
|
2265
|
-
sys.stderr.write("⚠️ Adaptive: CAPTCHA still present after
|
|
2687
|
+
sys.stderr.write(f"⚠️ Adaptive: CAPTCHA still present after '{next_tier}' retry. Manual solve needed.\n")
|
|
2266
2688
|
else:
|
|
2267
|
-
sys.stderr.write("✅ Adaptive: CAPTCHA cleared
|
|
2689
|
+
sys.stderr.write(f"✅ Adaptive: CAPTCHA cleared at tier '{next_tier}'.\n")
|
|
2268
2690
|
# Captcha solver auto-mode: if provider configured + auto on, attempt solve
|
|
2269
2691
|
try:
|
|
2270
2692
|
await _captcha_auto_solve_if_enabled(active_ws, info, url)
|
|
@@ -2773,6 +3195,188 @@ async def _humanize_click(ws_url, x, y):
|
|
|
2773
3195
|
await asyncio.sleep(r.uniform(0.08, 0.20))
|
|
2774
3196
|
|
|
2775
3197
|
|
|
3198
|
+
# ─── PerimeterX / HUMAN "Press & Hold" solver ───────────────────────────────
|
|
3199
|
+
# PerimeterX (HUMAN Security) gates content behind a button the user must press
|
|
3200
|
+
# AND HOLD for several seconds. The detector measures the mouse-hold *behaviour*
|
|
3201
|
+
# (press duration, micro-tremor during the hold, release timing) to tell a human
|
|
3202
|
+
# hand from a bot — it is NOT a token challenge, so there is nothing to fetch or
|
|
3203
|
+
# inject. The only way through is to emit a real, human-shaped press->hold->release
|
|
3204
|
+
# gesture. We reuse the existing humanized mouse path + Input.dispatchMouseEvent
|
|
3205
|
+
# (zero new deps, pure CDP) and add the natural hand-tremor PerimeterX looks for.
|
|
3206
|
+
|
|
3207
|
+
# JS that locates the press-and-hold target and returns its viewport centre.
|
|
3208
|
+
# Tries the canonical #px-captcha / [class*="px-captcha"] hooks first, then any
|
|
3209
|
+
# element whose visible text matches "press & hold" / "press and hold" / the
|
|
3210
|
+
# Turkish "basili tut". Returns JSON: {found, cx, cy, w, h, how} or {found:false}.
|
|
3211
|
+
PRESS_HOLD_FIND_JS = r"""
|
|
3212
|
+
(function(userSel) {
|
|
3213
|
+
try {
|
|
3214
|
+
function centre(el, how) {
|
|
3215
|
+
if (!el) return null;
|
|
3216
|
+
try { el.scrollIntoView({behavior: 'instant', block: 'center'}); } catch (e) {}
|
|
3217
|
+
var r = el.getBoundingClientRect();
|
|
3218
|
+
if (r.width < 1 || r.height < 1) return null;
|
|
3219
|
+
return JSON.stringify({
|
|
3220
|
+
found: true,
|
|
3221
|
+
cx: Math.round(r.left + r.width / 2),
|
|
3222
|
+
cy: Math.round(r.top + r.height / 2),
|
|
3223
|
+
w: Math.round(r.width), h: Math.round(r.height), how: how
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
if (userSel) {
|
|
3227
|
+
var u = document.querySelector(userSel);
|
|
3228
|
+
var cu = centre(u, 'user-selector');
|
|
3229
|
+
if (cu) return cu;
|
|
3230
|
+
}
|
|
3231
|
+
// Canonical PerimeterX hooks.
|
|
3232
|
+
var px = document.querySelector('#px-captcha, [class*="px-captcha"]');
|
|
3233
|
+
if (px) {
|
|
3234
|
+
// The visible hold target is often an inner button/div; prefer a child
|
|
3235
|
+
// with real size, else the container itself.
|
|
3236
|
+
var inner = px.querySelector('div[role="button"], button, [tabindex], div');
|
|
3237
|
+
var ci = centre(inner, 'px-inner');
|
|
3238
|
+
if (ci) return ci;
|
|
3239
|
+
var cc = centre(px, 'px-container');
|
|
3240
|
+
if (cc) return cc;
|
|
3241
|
+
}
|
|
3242
|
+
// Text-based fallback: any small interactive element saying press & hold.
|
|
3243
|
+
var needles = ['press & hold', 'press and hold', 'press & hold',
|
|
3244
|
+
'basili tut', 'basılı tut', 'press hold'];
|
|
3245
|
+
var cands = document.querySelectorAll(
|
|
3246
|
+
'button, [role="button"], div, span, a, p');
|
|
3247
|
+
for (var i = 0; i < cands.length; i++) {
|
|
3248
|
+
var el = cands[i];
|
|
3249
|
+
var t = (el.textContent || '').toLowerCase().trim();
|
|
3250
|
+
if (!t || t.length > 60) continue;
|
|
3251
|
+
for (var j = 0; j < needles.length; j++) {
|
|
3252
|
+
if (t.indexOf(needles[j]) >= 0) {
|
|
3253
|
+
var ct = centre(el, 'text-match');
|
|
3254
|
+
if (ct) return ct;
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
return JSON.stringify({ found: false });
|
|
3259
|
+
} catch (e) {
|
|
3260
|
+
return JSON.stringify({ found: false, error: String(e) });
|
|
3261
|
+
}
|
|
3262
|
+
})
|
|
3263
|
+
"""
|
|
3264
|
+
|
|
3265
|
+
|
|
3266
|
+
def _press_hold_duration_ms():
|
|
3267
|
+
"""Gaussian-randomized hold duration in ms. mu~4000, clamped 3000..7000.
|
|
3268
|
+
|
|
3269
|
+
PerimeterX rejects a fixed duration (machine-like). We draw from a Gaussian
|
|
3270
|
+
so each attempt looks individually human, then clamp to the band the widget
|
|
3271
|
+
actually accepts.
|
|
3272
|
+
"""
|
|
3273
|
+
return int(_gauss(4000, 900, 3000, 7000))
|
|
3274
|
+
|
|
3275
|
+
|
|
3276
|
+
async def _press_hold_gesture(ws_url, x, y, hold_ms):
|
|
3277
|
+
"""Emit one real press->hold(jitter)->release gesture at (x, y) over CDP.
|
|
3278
|
+
|
|
3279
|
+
1. Bezier-humanized mouse move to the target (reuses _humanize_mouse_move).
|
|
3280
|
+
2. Input.dispatchMouseEvent mousePressed (button:left) at the target.
|
|
3281
|
+
3. Hold loop: every 100-200ms emit a mouseMoved with +/-1-2px jitter WHILE the
|
|
3282
|
+
left button stays pressed -- this is the natural hand-tremor PerimeterX
|
|
3283
|
+
inspects. Total loop time == hold_ms.
|
|
3284
|
+
4. Input.dispatchMouseEvent mouseReleased.
|
|
3285
|
+
Returns the number of micro-jitter moves dispatched during the hold.
|
|
3286
|
+
"""
|
|
3287
|
+
import random as _r
|
|
3288
|
+
r = _r.Random(int(_ENTROPY_SEED)) if _ENTROPY_SEED else _r.Random()
|
|
3289
|
+
# Land the cursor on the target with a human path + small final offset.
|
|
3290
|
+
await _humanize_mouse_move(ws_url, x, y)
|
|
3291
|
+
await asyncio.sleep(r.uniform(0.05, 0.18))
|
|
3292
|
+
# Press and hold the left button at the target.
|
|
3293
|
+
await cdp_send(ws_url, [(981, "Input.dispatchMouseEvent", {
|
|
3294
|
+
"type": "mousePressed", "x": x, "y": y,
|
|
3295
|
+
"button": "left", "buttons": 1, "clickCount": 1})])
|
|
3296
|
+
jitters = 0
|
|
3297
|
+
elapsed = 0.0
|
|
3298
|
+
cur_x, cur_y = x, y
|
|
3299
|
+
hold_s = hold_ms / 1000.0
|
|
3300
|
+
while elapsed < hold_s:
|
|
3301
|
+
step = r.uniform(0.10, 0.20)
|
|
3302
|
+
# Don't overshoot the requested hold window.
|
|
3303
|
+
if elapsed + step > hold_s:
|
|
3304
|
+
step = max(0.0, hold_s - elapsed)
|
|
3305
|
+
await asyncio.sleep(step)
|
|
3306
|
+
elapsed += step
|
|
3307
|
+
if elapsed >= hold_s:
|
|
3308
|
+
break
|
|
3309
|
+
# +/-1-2px tremor around the press point, button still held (buttons:1).
|
|
3310
|
+
cur_x = x + r.randint(-2, 2)
|
|
3311
|
+
cur_y = y + r.randint(-2, 2)
|
|
3312
|
+
await cdp_send(ws_url, [(982, "Input.dispatchMouseEvent", {
|
|
3313
|
+
"type": "mouseMoved", "x": cur_x, "y": cur_y,
|
|
3314
|
+
"button": "left", "buttons": 1, "modifiers": 0})])
|
|
3315
|
+
jitters += 1
|
|
3316
|
+
# Release at (near) the press point.
|
|
3317
|
+
await cdp_send(ws_url, [(983, "Input.dispatchMouseEvent", {
|
|
3318
|
+
"type": "mouseReleased", "x": cur_x, "y": cur_y,
|
|
3319
|
+
"button": "left", "buttons": 0, "clickCount": 1})])
|
|
3320
|
+
return jitters
|
|
3321
|
+
|
|
3322
|
+
|
|
3323
|
+
async def _solve_press_and_hold(ws_url, target_sel=None):
|
|
3324
|
+
"""Solve a PerimeterX/HUMAN "Press & Hold" challenge with a humanized gesture.
|
|
3325
|
+
|
|
3326
|
+
Finds the hold target (#px-captcha / [class*="px-captcha"] / press-and-hold
|
|
3327
|
+
text, or an explicit target_sel), then performs press->hold(with micro-tremor)
|
|
3328
|
+
->release. Verifies via _detect_captcha; retries once with a different hold
|
|
3329
|
+
duration if the challenge is still present.
|
|
3330
|
+
|
|
3331
|
+
Returns: {"solved": bool, "method": "press_and_hold", "hold_ms": N,
|
|
3332
|
+
"attempts": N, ...}. Never raises.
|
|
3333
|
+
"""
|
|
3334
|
+
last_hold = 0
|
|
3335
|
+
attempts = 0
|
|
3336
|
+
max_attempts = 2
|
|
3337
|
+
for attempts in range(1, max_attempts + 1):
|
|
3338
|
+
# (Re)locate the target each attempt -- the widget may re-render.
|
|
3339
|
+
try:
|
|
3340
|
+
find_expr = f"({PRESS_HOLD_FIND_JS})({json.dumps(target_sel) if target_sel else 'null'})"
|
|
3341
|
+
res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
3342
|
+
"expression": find_expr, "returnByValue": True})], timeout=8)
|
|
3343
|
+
raw = res.get(1, {}).get("result", {}).get("value")
|
|
3344
|
+
loc = json.loads(raw) if raw else {"found": False}
|
|
3345
|
+
except Exception as e:
|
|
3346
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3347
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3348
|
+
"error": f"locate_failed:{str(e)[:80]}"}
|
|
3349
|
+
if not loc.get("found"):
|
|
3350
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3351
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3352
|
+
"error": "target_not_found"}
|
|
3353
|
+
|
|
3354
|
+
hold_ms = _press_hold_duration_ms()
|
|
3355
|
+
last_hold = hold_ms
|
|
3356
|
+
try:
|
|
3357
|
+
jitters = await _press_hold_gesture(
|
|
3358
|
+
ws_url, int(loc["cx"]), int(loc["cy"]), hold_ms)
|
|
3359
|
+
except Exception as e:
|
|
3360
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3361
|
+
"hold_ms": hold_ms, "attempts": attempts,
|
|
3362
|
+
"error": f"gesture_failed:{str(e)[:80]}"}
|
|
3363
|
+
|
|
3364
|
+
# Settle, then check whether the challenge cleared.
|
|
3365
|
+
import random as _r
|
|
3366
|
+
await asyncio.sleep((_r.Random().uniform(500, 1500)) / 1000.0)
|
|
3367
|
+
info = await _detect_captcha(ws_url)
|
|
3368
|
+
still_px = 'perimeterx' in (info.get('types') or [])
|
|
3369
|
+
if not still_px:
|
|
3370
|
+
return {"solved": True, "method": "press_and_hold",
|
|
3371
|
+
"hold_ms": hold_ms, "attempts": attempts,
|
|
3372
|
+
"jitter_moves": jitters}
|
|
3373
|
+
# else: loop and try once more with a fresh (re-drawn) hold duration.
|
|
3374
|
+
|
|
3375
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3376
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3377
|
+
"error": "still_present_after_retry"}
|
|
3378
|
+
|
|
3379
|
+
|
|
2776
3380
|
async def _humanize_type(ws_url, text):
|
|
2777
3381
|
"""Type text with Gaussian inter-key and dwell delays via CDP key events."""
|
|
2778
3382
|
import random as _r
|
|
@@ -3873,10 +4477,158 @@ async def cmd_debug(url=None):
|
|
|
3873
4477
|
print(f"Debug complete: {url}")
|
|
3874
4478
|
|
|
3875
4479
|
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
4480
|
+
# ─── Owned-Tab Tracking ───
|
|
4481
|
+
# cdpilot records every target_id it opens so that `close` can distinguish its
|
|
4482
|
+
# own tabs from tabs the user opened manually in the same isolated profile.
|
|
4483
|
+
# Stored as a JSON list in the project profile dir; survives across CLI
|
|
4484
|
+
# invocations (each `go`/`new-tab` is a separate process).
|
|
4485
|
+
|
|
4486
|
+
def _load_owned_tabs():
|
|
4487
|
+
"""Return the set of target_ids cdpilot opened in this project profile."""
|
|
4488
|
+
try:
|
|
4489
|
+
with open(OWNED_TABS_FILE) as f:
|
|
4490
|
+
data = json.load(f)
|
|
4491
|
+
return set(data.get("owned", []))
|
|
4492
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
4493
|
+
return set()
|
|
4494
|
+
|
|
4495
|
+
|
|
4496
|
+
def _save_owned_tabs(owned):
|
|
4497
|
+
"""Persist the owned target_id set."""
|
|
4498
|
+
try:
|
|
4499
|
+
os.makedirs(os.path.dirname(OWNED_TABS_FILE), exist_ok=True)
|
|
4500
|
+
with open(OWNED_TABS_FILE, "w") as f:
|
|
4501
|
+
json.dump({"owned": sorted(owned)}, f)
|
|
4502
|
+
except OSError:
|
|
4503
|
+
pass
|
|
4504
|
+
|
|
4505
|
+
|
|
4506
|
+
def _mark_owned_tab(target_id):
|
|
4507
|
+
"""Record a target_id as cdpilot-owned. No-op on falsy input."""
|
|
4508
|
+
if not target_id:
|
|
4509
|
+
return
|
|
4510
|
+
owned = _load_owned_tabs()
|
|
4511
|
+
if target_id not in owned:
|
|
4512
|
+
owned.add(target_id)
|
|
4513
|
+
_save_owned_tabs(owned)
|
|
4514
|
+
|
|
4515
|
+
|
|
4516
|
+
def _is_chrome_internal_url(url):
|
|
4517
|
+
"""True for blank/new-tab/internal pages that don't count as 'real' tabs."""
|
|
4518
|
+
if not url:
|
|
4519
|
+
return True
|
|
4520
|
+
u = url.strip().lower()
|
|
4521
|
+
return (u in ("about:blank", "about:newtab", "")
|
|
4522
|
+
or u.startswith("chrome://")
|
|
4523
|
+
or u.startswith("edge://")
|
|
4524
|
+
or u.startswith("chrome-extension://")
|
|
4525
|
+
or u.startswith("devtools://")
|
|
4526
|
+
or u.startswith("brave://")
|
|
4527
|
+
or u.startswith("vivaldi://"))
|
|
4528
|
+
|
|
4529
|
+
|
|
4530
|
+
async def _browser_close_graceful():
|
|
4531
|
+
"""Ask the browser to shut down gracefully via the CDP Browser.close
|
|
4532
|
+
command (cleanest cross-platform path — flushes state, no orphaned procs).
|
|
4533
|
+
Returns True if the command was acknowledged. Falls back to a SIGTERM-based
|
|
4534
|
+
process stop (never kill -9) when the WebSocket path fails."""
|
|
4535
|
+
try:
|
|
4536
|
+
browser_ws = await _get_browser_ws()
|
|
4537
|
+
if browser_ws:
|
|
4538
|
+
await cdp_send(browser_ws, [(1, "Browser.close", {})], timeout=5)
|
|
4539
|
+
return True
|
|
4540
|
+
except SystemExit:
|
|
4541
|
+
raise
|
|
4542
|
+
except Exception:
|
|
4543
|
+
pass
|
|
4544
|
+
# Fallback: graceful OS-level termination (SIGTERM on POSIX, taskkill on
|
|
4545
|
+
# Windows). kill -9 is intentionally NOT used here — last resort only.
|
|
4546
|
+
return _stop_browser_on_port(CDP_PORT)
|
|
4547
|
+
|
|
4548
|
+
|
|
4549
|
+
async def cmd_close(force_browser=False, keep_browser=False):
|
|
4550
|
+
"""Smart close: shut down cdpilot's own tabs, then close the whole browser
|
|
4551
|
+
if no user tabs remain.
|
|
4552
|
+
|
|
4553
|
+
Behavior:
|
|
4554
|
+
1. Close every cdpilot-owned tab (CDP Target.closeTarget).
|
|
4555
|
+
2. Inspect the remaining page targets:
|
|
4556
|
+
- 0 real tabs left (only about:blank / chrome:// / new-tab) ->
|
|
4557
|
+
close the browser application gracefully (Browser.close, SIGTERM
|
|
4558
|
+
fallback — never kill -9).
|
|
4559
|
+
- one or more user tabs remain -> leave the browser open; only the
|
|
4560
|
+
owned tabs were closed.
|
|
4561
|
+
|
|
4562
|
+
Flags:
|
|
4563
|
+
keep_browser — never close the browser, only the owned tabs.
|
|
4564
|
+
force_browser — close the browser even if user tabs remain.
|
|
4565
|
+
"""
|
|
4566
|
+
if not cdp_get("/json/version"):
|
|
4567
|
+
print("No browser running.")
|
|
4568
|
+
return
|
|
4569
|
+
|
|
4570
|
+
tabs = get_tabs()
|
|
4571
|
+
pages = [t for t in tabs if t.get("type") == "page"]
|
|
4572
|
+
owned = _load_owned_tabs()
|
|
4573
|
+
|
|
4574
|
+
# Make sure the current session window counts as owned even if it predates
|
|
4575
|
+
# tracking (legacy session created before this feature shipped).
|
|
4576
|
+
session_target = _get_session_window_target_id()
|
|
4577
|
+
if session_target:
|
|
4578
|
+
owned.add(session_target)
|
|
4579
|
+
|
|
4580
|
+
browser_ws = await _get_browser_ws()
|
|
4581
|
+
closed = 0
|
|
4582
|
+
for p in pages:
|
|
4583
|
+
tid = p.get("id")
|
|
4584
|
+
if tid and tid in owned:
|
|
4585
|
+
try:
|
|
4586
|
+
await cdp_send(browser_ws, [(1, "Target.closeTarget",
|
|
4587
|
+
{"targetId": tid})], timeout=5)
|
|
4588
|
+
closed += 1
|
|
4589
|
+
except Exception:
|
|
4590
|
+
pass
|
|
4591
|
+
|
|
4592
|
+
# Owned set is consumed — clear tracking + the CWD session window pointer so
|
|
4593
|
+
# a later command starts clean.
|
|
4594
|
+
_save_owned_tabs(set())
|
|
4595
|
+
if session_target:
|
|
4596
|
+
sessions = _load_sessions()
|
|
4597
|
+
sid = _get_session_id()
|
|
4598
|
+
sessions.pop(sid, None)
|
|
4599
|
+
_save_sessions(sessions)
|
|
4600
|
+
cdp_cache_invalidate()
|
|
4601
|
+
|
|
4602
|
+
print(f"Closed {closed} cdpilot tab(s).")
|
|
4603
|
+
|
|
4604
|
+
# Re-read remaining targets after closing owned tabs.
|
|
4605
|
+
remaining = get_tabs()
|
|
4606
|
+
remaining_pages = [t for t in remaining if t.get("type") == "page"]
|
|
4607
|
+
user_pages = [p for p in remaining_pages
|
|
4608
|
+
if not _is_chrome_internal_url(p.get("url"))]
|
|
4609
|
+
|
|
4610
|
+
if keep_browser:
|
|
4611
|
+
if user_pages:
|
|
4612
|
+
print(f"Browser left open ({len(user_pages)} user tab(s)).")
|
|
4613
|
+
else:
|
|
4614
|
+
print("Browser left open (--keep).")
|
|
4615
|
+
return
|
|
4616
|
+
|
|
4617
|
+
if user_pages and not force_browser:
|
|
4618
|
+
print(f"Browser left open — {len(user_pages)} user tab(s) still present.")
|
|
4619
|
+
return
|
|
4620
|
+
|
|
4621
|
+
# No user tabs remain (or --force) -> close the browser gracefully.
|
|
4622
|
+
if await _browser_close_graceful():
|
|
4623
|
+
print(f"Browser closed gracefully (port {CDP_PORT}).")
|
|
4624
|
+
if PROJECT_ID:
|
|
4625
|
+
registry = _load_registry()
|
|
4626
|
+
if PROJECT_ID in registry:
|
|
4627
|
+
registry[PROJECT_ID]["status"] = "stopped"
|
|
4628
|
+
registry[PROJECT_ID]["pid"] = None
|
|
4629
|
+
_save_registry(registry)
|
|
4630
|
+
else:
|
|
4631
|
+
print("Browser close failed.", file=sys.stderr)
|
|
3880
4632
|
|
|
3881
4633
|
|
|
3882
4634
|
def cmd_session():
|
|
@@ -4300,6 +5052,7 @@ async def cmd_new_tab(url='about:blank'):
|
|
|
4300
5052
|
data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
|
|
4301
5053
|
cdp_cache_invalidate()
|
|
4302
5054
|
if data:
|
|
5055
|
+
_mark_owned_tab(data.get("id"))
|
|
4303
5056
|
print(f'New tab opened: {data.get("url", url)}')
|
|
4304
5057
|
print(f' ID: {data.get("id", "?")}')
|
|
4305
5058
|
else:
|
|
@@ -4870,6 +5623,14 @@ def cmd_stealth(state=None):
|
|
|
4870
5623
|
os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
|
|
4871
5624
|
with open(STEALTH_CONFIG_FILE, 'w') as f:
|
|
4872
5625
|
json.dump({'stealth': enabled}, f)
|
|
5626
|
+
# Backward-compat bridge to the three-tier model: keep mode.json coherent
|
|
5627
|
+
# with the legacy toggle so `stealth on` -> 'undetected' (full patch) and
|
|
5628
|
+
# `stealth off` -> 'regular'. Without this, an existing mode.json would
|
|
5629
|
+
# silently override the toggle (get_mode_config reads mode.json first).
|
|
5630
|
+
try:
|
|
5631
|
+
set_mode_config('undetected' if enabled else 'regular')
|
|
5632
|
+
except Exception:
|
|
5633
|
+
pass
|
|
4873
5634
|
print(f'Stealth: {"on" if enabled else "off"}')
|
|
4874
5635
|
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
4875
5636
|
|
|
@@ -5128,18 +5889,25 @@ async def _assert_host(ws_url, expected_host):
|
|
|
5128
5889
|
|
|
5129
5890
|
|
|
5130
5891
|
def get_adaptive_config():
|
|
5131
|
-
"""Return
|
|
5892
|
+
"""Return adaptive config with all learned-state keys carried through.
|
|
5893
|
+
|
|
5894
|
+
Keys: enabled, stealth_hosts, entropy_hosts, host_tiers. The latter two
|
|
5895
|
+
MUST be preserved here because callers write the whole dict back via
|
|
5896
|
+
_atomic_write_json — dropping a key would silently erase learned state.
|
|
5897
|
+
"""
|
|
5132
5898
|
if not os.path.exists(ADAPTIVE_CONFIG_FILE):
|
|
5133
|
-
return {'enabled': False, 'stealth_hosts': []}
|
|
5899
|
+
return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
|
|
5134
5900
|
try:
|
|
5135
5901
|
with open(ADAPTIVE_CONFIG_FILE) as f:
|
|
5136
5902
|
data = json.load(f)
|
|
5137
5903
|
return {
|
|
5138
5904
|
'enabled': bool(data.get('enabled', False)),
|
|
5139
5905
|
'stealth_hosts': list(data.get('stealth_hosts', [])),
|
|
5906
|
+
'entropy_hosts': dict(data.get('entropy_hosts', {})),
|
|
5907
|
+
'host_tiers': dict(data.get('host_tiers', {})),
|
|
5140
5908
|
}
|
|
5141
5909
|
except (OSError, ValueError):
|
|
5142
|
-
return {'enabled': False, 'stealth_hosts': []}
|
|
5910
|
+
return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
|
|
5143
5911
|
|
|
5144
5912
|
|
|
5145
5913
|
def _adaptive_remember_host(hostname):
|
|
@@ -5151,6 +5919,48 @@ def _adaptive_remember_host(hostname):
|
|
|
5151
5919
|
_atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
|
|
5152
5920
|
|
|
5153
5921
|
|
|
5922
|
+
def _escalate_tier(tier):
|
|
5923
|
+
"""Return the next-stronger tier. undetected is the ceiling."""
|
|
5924
|
+
try:
|
|
5925
|
+
i = MODE_TIERS.index(tier)
|
|
5926
|
+
except ValueError:
|
|
5927
|
+
i = 0
|
|
5928
|
+
return MODE_TIERS[min(i + 1, len(MODE_TIERS) - 1)]
|
|
5929
|
+
|
|
5930
|
+
|
|
5931
|
+
def _adaptive_remember_host_tier(hostname, tier):
|
|
5932
|
+
"""Record the tier a host needs in adaptive.json under 'host_tiers'.
|
|
5933
|
+
|
|
5934
|
+
Monotonic: only ever ratchets UP (regular -> stealth -> undetected),
|
|
5935
|
+
never down — mirrors the never-auto-demote policy of the stealth_hosts
|
|
5936
|
+
list so a single false-negative CAPTCHA detection can't downgrade a host.
|
|
5937
|
+
"""
|
|
5938
|
+
if tier not in MODE_TIERS:
|
|
5939
|
+
return
|
|
5940
|
+
cfg = get_adaptive_config()
|
|
5941
|
+
host_tiers = cfg.get('host_tiers', {})
|
|
5942
|
+
prev = host_tiers.get(hostname)
|
|
5943
|
+
if prev in MODE_TIERS and MODE_TIERS.index(prev) >= MODE_TIERS.index(tier):
|
|
5944
|
+
return # already at or above this tier, skip write
|
|
5945
|
+
host_tiers[hostname] = tier
|
|
5946
|
+
cfg['host_tiers'] = host_tiers
|
|
5947
|
+
_atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
|
|
5948
|
+
|
|
5949
|
+
|
|
5950
|
+
def _adaptive_host_tier(url):
|
|
5951
|
+
"""Return the learned tier for a URL's host, or None if none/disabled."""
|
|
5952
|
+
cfg = get_adaptive_config()
|
|
5953
|
+
if not cfg.get('enabled'):
|
|
5954
|
+
return None
|
|
5955
|
+
try:
|
|
5956
|
+
from urllib.parse import urlparse
|
|
5957
|
+
host = (urlparse(url).hostname or '').lower()
|
|
5958
|
+
except Exception:
|
|
5959
|
+
return None
|
|
5960
|
+
tier = cfg.get('host_tiers', {}).get(host)
|
|
5961
|
+
return tier if tier in MODE_TIERS else None
|
|
5962
|
+
|
|
5963
|
+
|
|
5154
5964
|
def _adaptive_host_requires_stealth(url):
|
|
5155
5965
|
"""True if the URL's hostname is in the adaptive stealth list."""
|
|
5156
5966
|
cfg = get_adaptive_config()
|
|
@@ -5281,35 +6091,169 @@ async def _detect_captcha(ws_url):
|
|
|
5281
6091
|
return {"detected": False, "error": str(e)[:120]}
|
|
5282
6092
|
|
|
5283
6093
|
|
|
5284
|
-
|
|
5285
|
-
|
|
6094
|
+
# ─── Progressive-Resilience Escalation Ladder ───
|
|
6095
|
+
# Friction levels ordered low -> high. soft_captcha is detected by the existing
|
|
6096
|
+
# _detect_captcha (9 provider types); the other rungs come from
|
|
6097
|
+
# FRICTION_DETECT_JS. _detect_friction merges both and reports the highest.
|
|
6098
|
+
FRICTION_LEVELS = (
|
|
6099
|
+
'none', 'rate_limited', 'soft_captcha', 'login_wall', 'otp_sms', 'hard_block',
|
|
6100
|
+
)
|
|
5286
6101
|
|
|
5287
|
-
Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
|
|
5288
|
-
Useful in scripts: `cdpilot captcha-check && do-stuff`.
|
|
5289
|
-
"""
|
|
5290
|
-
ws, _ = get_page_ws()
|
|
5291
|
-
info = await _detect_captcha(ws)
|
|
5292
|
-
print(json.dumps(info, ensure_ascii=False))
|
|
5293
|
-
if info.get("error") and not info.get("detected"):
|
|
5294
|
-
sys.exit(1)
|
|
5295
|
-
sys.exit(3 if info.get("detected") else 0)
|
|
5296
6102
|
|
|
6103
|
+
def _friction_backoff_enabled():
|
|
6104
|
+
"""Whether cmd_go should auto-backoff on rate_limited. Default on."""
|
|
6105
|
+
return os.environ.get('CDPILOT_FRICTION_BACKOFF', 'on').lower() not in ('off', '0', 'false', 'no')
|
|
5297
6106
|
|
|
5298
|
-
async def cmd_captcha_wait(timeout_arg=None):
|
|
5299
|
-
"""Detect CAPTCHA and wait for the user to solve it.
|
|
5300
6107
|
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
6108
|
+
def _friction_max_retry():
|
|
6109
|
+
"""Max rate-limit backoff retries in cmd_go. Default 2, clamped 0..5."""
|
|
6110
|
+
try:
|
|
6111
|
+
n = int(os.environ.get('CDPILOT_FRICTION_MAX_RETRY', '2'))
|
|
6112
|
+
except (ValueError, TypeError):
|
|
6113
|
+
n = 2
|
|
6114
|
+
return max(0, min(n, 5))
|
|
5306
6115
|
|
|
5307
|
-
Args:
|
|
5308
|
-
timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
|
|
5309
6116
|
|
|
5310
|
-
|
|
6117
|
+
def _friction_backoff_seconds(attempt):
|
|
6118
|
+
"""Exponential backoff with jitter: 2^attempt seconds, capped at 60s.
|
|
6119
|
+
|
|
6120
|
+
attempt is 0-based (first retry -> 2s base). Adds up to 25% jitter so
|
|
6121
|
+
concurrent workers don't synchronize their retries.
|
|
5311
6122
|
"""
|
|
5312
|
-
|
|
6123
|
+
import random as _r
|
|
6124
|
+
base = min(2 ** (attempt + 1), 60)
|
|
6125
|
+
jitter = _r.uniform(0, base * 0.25)
|
|
6126
|
+
return round(min(base + jitter, 60), 2)
|
|
6127
|
+
|
|
6128
|
+
|
|
6129
|
+
def _friction_action(level):
|
|
6130
|
+
"""Map a friction level to the response policy cmd_go should apply.
|
|
6131
|
+
|
|
6132
|
+
ETHICS/SAFETY: login_wall, otp_sms and hard_block are NEVER bypassed
|
|
6133
|
+
autonomously — they hand off to the human. Only rate_limited is auto-handled
|
|
6134
|
+
(backoff + retry); soft_captcha defers to the existing captcha flow.
|
|
6135
|
+
|
|
6136
|
+
Returns a dict: {action, level, autonomous: bool, message}.
|
|
6137
|
+
"""
|
|
6138
|
+
if level == 'rate_limited':
|
|
6139
|
+
return {
|
|
6140
|
+
'action': 'backoff', 'level': level, 'autonomous': True,
|
|
6141
|
+
'message': 'Rate limited — exponential backoff then retry.',
|
|
6142
|
+
}
|
|
6143
|
+
if level == 'soft_captcha':
|
|
6144
|
+
return {
|
|
6145
|
+
'action': 'captcha', 'level': level, 'autonomous': True,
|
|
6146
|
+
'message': 'CAPTCHA — defer to detect/wait/solver flow.',
|
|
6147
|
+
}
|
|
6148
|
+
if level == 'login_wall':
|
|
6149
|
+
return {
|
|
6150
|
+
'action': 'human_login_required', 'level': level, 'autonomous': False,
|
|
6151
|
+
'message': '🔐 Login gerekli — kullanıcı girişi bekleniyor (otomatik giriş YOK).',
|
|
6152
|
+
}
|
|
6153
|
+
if level == 'otp_sms':
|
|
6154
|
+
return {
|
|
6155
|
+
'action': 'human_otp_required', 'level': level, 'autonomous': False,
|
|
6156
|
+
'message': '📱 SMS/OTP doğrulama gerekli — kullanıcı çözmeli (otomatik çözüm YOK).',
|
|
6157
|
+
}
|
|
6158
|
+
if level == 'hard_block':
|
|
6159
|
+
return {
|
|
6160
|
+
'action': 'hard_blocked', 'level': level, 'autonomous': False,
|
|
6161
|
+
'message': '🚫 Sert engelleme — geri çekil ve bekle.', 'backoff_suggested': True,
|
|
6162
|
+
}
|
|
6163
|
+
return {'action': 'proceed', 'level': 'none', 'autonomous': True, 'message': 'No friction.'}
|
|
6164
|
+
|
|
6165
|
+
|
|
6166
|
+
async def _detect_friction(ws_url):
|
|
6167
|
+
"""Detect the highest anti-bot friction rung on the active page.
|
|
6168
|
+
|
|
6169
|
+
Runs FRICTION_DETECT_JS (rate-limit / login-wall / OTP / hard-block) and
|
|
6170
|
+
merges in the existing _detect_captcha result (soft_captcha). Returns the
|
|
6171
|
+
HIGHEST rung present per FRICTION_LEVELS ordering.
|
|
6172
|
+
|
|
6173
|
+
Returns: {"level": str, "signals": [...], "detail": str, "captcha"?: {...}}
|
|
6174
|
+
Never raises — failures degrade to {"level": "none", ...}.
|
|
6175
|
+
"""
|
|
6176
|
+
friction = {"level": "none", "signals": [], "detail": ""}
|
|
6177
|
+
try:
|
|
6178
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
6179
|
+
"expression": FRICTION_DETECT_JS,
|
|
6180
|
+
"returnByValue": True,
|
|
6181
|
+
})], timeout=5)
|
|
6182
|
+
raw = r.get(1, {}).get("result", {}).get("value")
|
|
6183
|
+
if raw:
|
|
6184
|
+
try:
|
|
6185
|
+
parsed = json.loads(raw)
|
|
6186
|
+
if isinstance(parsed, dict) and parsed.get("level") in FRICTION_LEVELS:
|
|
6187
|
+
friction = parsed
|
|
6188
|
+
except (ValueError, TypeError):
|
|
6189
|
+
pass
|
|
6190
|
+
except Exception as e:
|
|
6191
|
+
friction = {"level": "none", "signals": [], "detail": "", "error": str(e)[:120]}
|
|
6192
|
+
|
|
6193
|
+
# Merge soft_captcha from the dedicated captcha detector.
|
|
6194
|
+
try:
|
|
6195
|
+
cap = await _detect_captcha(ws_url)
|
|
6196
|
+
except Exception:
|
|
6197
|
+
cap = {"detected": False}
|
|
6198
|
+
if cap.get("detected"):
|
|
6199
|
+
cap_level = 'soft_captcha'
|
|
6200
|
+
# Keep whichever rung is higher in the ladder.
|
|
6201
|
+
cur_idx = FRICTION_LEVELS.index(friction.get('level', 'none')) if friction.get('level') in FRICTION_LEVELS else 0
|
|
6202
|
+
cap_idx = FRICTION_LEVELS.index(cap_level)
|
|
6203
|
+
if cap_idx > cur_idx:
|
|
6204
|
+
sigs = list(friction.get('signals', []))
|
|
6205
|
+
sigs.append('captcha:' + (",".join(cap.get('types', [])) or 'unknown'))
|
|
6206
|
+
friction = {
|
|
6207
|
+
'level': cap_level, 'signals': sigs,
|
|
6208
|
+
'detail': 'CAPTCHA challenge present (' + (",".join(cap.get('types', [])) or 'unknown') + ').',
|
|
6209
|
+
}
|
|
6210
|
+
friction['captcha'] = {'types': cap.get('types', []), 'detected': True}
|
|
6211
|
+
return friction
|
|
6212
|
+
|
|
6213
|
+
|
|
6214
|
+
async def cmd_friction():
|
|
6215
|
+
"""One-shot friction-level diagnostic on the active page. Prints JSON.
|
|
6216
|
+
|
|
6217
|
+
Reports the highest escalation rung (none/rate_limited/soft_captcha/
|
|
6218
|
+
login_wall/otp_sms/hard_block) plus the recommended response policy.
|
|
6219
|
+
Read-only — never attempts to bypass anything.
|
|
6220
|
+
"""
|
|
6221
|
+
ws, _ = get_page_ws()
|
|
6222
|
+
info = await _detect_friction(ws)
|
|
6223
|
+
action = _friction_action(info.get('level', 'none'))
|
|
6224
|
+
info['recommended'] = action
|
|
6225
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
6226
|
+
|
|
6227
|
+
|
|
6228
|
+
async def cmd_captcha_check():
|
|
6229
|
+
"""One-shot CAPTCHA detection on the active page. Prints JSON.
|
|
6230
|
+
|
|
6231
|
+
Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
|
|
6232
|
+
Useful in scripts: `cdpilot captcha-check && do-stuff`.
|
|
6233
|
+
"""
|
|
6234
|
+
ws, _ = get_page_ws()
|
|
6235
|
+
info = await _detect_captcha(ws)
|
|
6236
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
6237
|
+
if info.get("error") and not info.get("detected"):
|
|
6238
|
+
sys.exit(1)
|
|
6239
|
+
sys.exit(3 if info.get("detected") else 0)
|
|
6240
|
+
|
|
6241
|
+
|
|
6242
|
+
async def cmd_captcha_wait(timeout_arg=None):
|
|
6243
|
+
"""Detect CAPTCHA and wait for the user to solve it.
|
|
6244
|
+
|
|
6245
|
+
Behavior:
|
|
6246
|
+
- Interactive (stdin is a TTY): print warning, block on Enter.
|
|
6247
|
+
- Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
|
|
6248
|
+
disappears or timeout expires; emit one JSON line per poll on
|
|
6249
|
+
state change so callers can stream progress.
|
|
6250
|
+
|
|
6251
|
+
Args:
|
|
6252
|
+
timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
|
|
6253
|
+
|
|
6254
|
+
Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
|
|
6255
|
+
"""
|
|
6256
|
+
try:
|
|
5313
6257
|
timeout = int(timeout_arg) if timeout_arg else 300
|
|
5314
6258
|
except (ValueError, TypeError):
|
|
5315
6259
|
print(f"Invalid timeout: {timeout_arg}", file=sys.stderr)
|
|
@@ -6089,6 +7033,387 @@ async def cmd_captcha_dispatch(args: list) -> None:
|
|
|
6089
7033
|
sys.exit(1)
|
|
6090
7034
|
|
|
6091
7035
|
|
|
7036
|
+
# ─── Amazon classic image CAPTCHA (local OCR + BYOK image solvers) ────────
|
|
7037
|
+
# amazon.com rate-limit page: "Enter the characters you see below". The image
|
|
7038
|
+
# is a 6-char distorted glyph string at a fixed URL pattern. The optional
|
|
7039
|
+
# `amazoncaptcha` PyPI lib (pure-Python + Pillow, MIT) OCRs it offline with
|
|
7040
|
+
# ~90% accuracy. It is NOT a mandatory dependency — imported lazily.
|
|
7041
|
+
# BYOK fallback: CapSolver / 2Captcha image-to-text endpoints (HTTP only).
|
|
7042
|
+
|
|
7043
|
+
|
|
7044
|
+
async def _detect_amazon_captcha(ws_url: str) -> dict:
|
|
7045
|
+
"""Return {'detected': bool, 'img_src': str, 'input_id': str} for Amazon classic CAPTCHA.
|
|
7046
|
+
|
|
7047
|
+
Probes the active page for the #captchacharacters input + captcha image.
|
|
7048
|
+
Never raises.
|
|
7049
|
+
"""
|
|
7050
|
+
info = await _detect_captcha(ws_url)
|
|
7051
|
+
if not info.get('detected'):
|
|
7052
|
+
return {'detected': False}
|
|
7053
|
+
for d in info.get('details', []):
|
|
7054
|
+
if d.get('type') == 'amazon-classic':
|
|
7055
|
+
return {
|
|
7056
|
+
'detected': True,
|
|
7057
|
+
'img_src': d.get('img_src', ''),
|
|
7058
|
+
'input_id': d.get('input_id', 'captchacharacters'),
|
|
7059
|
+
}
|
|
7060
|
+
return {'detected': False}
|
|
7061
|
+
|
|
7062
|
+
|
|
7063
|
+
async def _fetch_image_bytes(ws_url: str, img_src: str) -> bytes | None:
|
|
7064
|
+
"""Download the captcha image bytes. Uses urllib (image URLs are public CDN)."""
|
|
7065
|
+
if not img_src:
|
|
7066
|
+
return None
|
|
7067
|
+
try:
|
|
7068
|
+
return await _captcha_urlopen_async(img_src, timeout=15)
|
|
7069
|
+
except Exception:
|
|
7070
|
+
return None
|
|
7071
|
+
|
|
7072
|
+
|
|
7073
|
+
def _solve_amazon_local(img_bytes: bytes) -> str | None:
|
|
7074
|
+
"""OCR Amazon classic CAPTCHA with the OPTIONAL amazoncaptcha library.
|
|
7075
|
+
|
|
7076
|
+
Returns the solved string, or None if the library is unavailable or fails.
|
|
7077
|
+
Raises ImportError-as-signal via returning None; caller prints install hint.
|
|
7078
|
+
"""
|
|
7079
|
+
try:
|
|
7080
|
+
from amazoncaptcha import AmazonCaptcha # optional dependency
|
|
7081
|
+
except ImportError:
|
|
7082
|
+
return None
|
|
7083
|
+
try:
|
|
7084
|
+
import io as _io
|
|
7085
|
+
cap = AmazonCaptcha(_io.BytesIO(img_bytes))
|
|
7086
|
+
solution = cap.solve()
|
|
7087
|
+
if solution and solution.lower() != 'not solved':
|
|
7088
|
+
return solution
|
|
7089
|
+
except Exception:
|
|
7090
|
+
return None
|
|
7091
|
+
return None
|
|
7092
|
+
|
|
7093
|
+
|
|
7094
|
+
async def _solve_image_byok(img_bytes: bytes) -> dict:
|
|
7095
|
+
"""Solve an image CAPTCHA via a BYOK provider (CapSolver / 2Captcha).
|
|
7096
|
+
|
|
7097
|
+
Reads CAPSOLVER_API_KEY then TWOCAPTCHA_API_KEY from the environment.
|
|
7098
|
+
Only stdlib HTTP — no new dependency. Returns {'token': text} or {'error': ...}.
|
|
7099
|
+
"""
|
|
7100
|
+
import base64 as _b64
|
|
7101
|
+
b64 = _b64.b64encode(img_bytes).decode('ascii')
|
|
7102
|
+
|
|
7103
|
+
capsolver_key = os.environ.get('CAPSOLVER_API_KEY')
|
|
7104
|
+
if capsolver_key:
|
|
7105
|
+
try:
|
|
7106
|
+
payload = json.dumps({
|
|
7107
|
+
'clientKey': capsolver_key,
|
|
7108
|
+
'task': {'type': 'ImageToTextTask', 'body': b64},
|
|
7109
|
+
}).encode()
|
|
7110
|
+
raw = await _captcha_urlopen_async(
|
|
7111
|
+
'https://api.capsolver.com/createTask', data=payload,
|
|
7112
|
+
headers={'Content-Type': 'application/json'}, timeout=60,
|
|
7113
|
+
)
|
|
7114
|
+
r = json.loads(raw)
|
|
7115
|
+
if r.get('errorId', 0) == 0:
|
|
7116
|
+
text = (r.get('solution') or {}).get('text', '')
|
|
7117
|
+
if text:
|
|
7118
|
+
return {'token': text, 'provider': 'capsolver'}
|
|
7119
|
+
return {'error': f"capsolver: {r.get('errorDescription', r)}"}
|
|
7120
|
+
except Exception as e:
|
|
7121
|
+
return {'error': f'capsolver: {e}'}
|
|
7122
|
+
|
|
7123
|
+
twocaptcha_key = os.environ.get('TWOCAPTCHA_API_KEY')
|
|
7124
|
+
if twocaptcha_key:
|
|
7125
|
+
import urllib.parse as _uparse
|
|
7126
|
+
try:
|
|
7127
|
+
body = _uparse.urlencode({
|
|
7128
|
+
'key': twocaptcha_key, 'method': 'base64',
|
|
7129
|
+
'body': b64, 'json': '1',
|
|
7130
|
+
}).encode()
|
|
7131
|
+
raw = await _captcha_urlopen_async('https://2captcha.com/in.php', data=body, timeout=30)
|
|
7132
|
+
resp = json.loads(raw)
|
|
7133
|
+
if resp.get('status') != 1:
|
|
7134
|
+
return {'error': f"2captcha submit: {resp.get('request', resp)}"}
|
|
7135
|
+
task_id = resp['request']
|
|
7136
|
+
poll = f"https://2captcha.com/res.php?key={twocaptcha_key}&action=get&id={task_id}&json=1"
|
|
7137
|
+
deadline = time.time() + CAPTCHA_SOLVE_TIMEOUT
|
|
7138
|
+
while time.time() < deadline:
|
|
7139
|
+
await asyncio.sleep(5)
|
|
7140
|
+
r2 = json.loads(await _captcha_urlopen_async(poll, timeout=15))
|
|
7141
|
+
if r2.get('status') == 1:
|
|
7142
|
+
return {'token': r2['request'], 'provider': '2captcha'}
|
|
7143
|
+
if r2.get('request') != 'CAPCHA_NOT_READY':
|
|
7144
|
+
return {'error': f"2captcha: {r2.get('request')}"}
|
|
7145
|
+
return {'error': '2captcha timeout'}
|
|
7146
|
+
except Exception as e:
|
|
7147
|
+
return {'error': f'2captcha: {e}'}
|
|
7148
|
+
|
|
7149
|
+
return {'error': 'no_byok_key'}
|
|
7150
|
+
|
|
7151
|
+
|
|
7152
|
+
async def _fill_amazon_solution(ws_url: str, input_id: str, solution: str) -> bool:
|
|
7153
|
+
"""Type the solution into the Amazon CAPTCHA input and submit. Returns True on success."""
|
|
7154
|
+
safe_id = json.dumps(input_id)
|
|
7155
|
+
safe_val = json.dumps(solution)
|
|
7156
|
+
js = f"""
|
|
7157
|
+
(function() {{
|
|
7158
|
+
try {{
|
|
7159
|
+
var el = document.getElementById({safe_id}) || document.querySelector('#captchacharacters');
|
|
7160
|
+
if (!el) return false;
|
|
7161
|
+
var setter = Object.getOwnPropertyDescriptor(
|
|
7162
|
+
window.HTMLInputElement.prototype, 'value').set;
|
|
7163
|
+
setter.call(el, {safe_val});
|
|
7164
|
+
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
7165
|
+
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
7166
|
+
var form = el.form || el.closest('form');
|
|
7167
|
+
if (form) {{ form.submit(); return true; }}
|
|
7168
|
+
var btn = document.querySelector('button[type=submit], input[type=submit]');
|
|
7169
|
+
if (btn) {{ btn.click(); return true; }}
|
|
7170
|
+
return true;
|
|
7171
|
+
}} catch(e) {{ return false; }}
|
|
7172
|
+
}})()
|
|
7173
|
+
"""
|
|
7174
|
+
try:
|
|
7175
|
+
r = await cdp_send(ws_url, [(1, 'Runtime.evaluate', {
|
|
7176
|
+
'expression': js, 'returnByValue': True,
|
|
7177
|
+
})], timeout=5)
|
|
7178
|
+
return bool(r.get(1, {}).get('result', {}).get('value', False))
|
|
7179
|
+
except Exception:
|
|
7180
|
+
return False
|
|
7181
|
+
|
|
7182
|
+
|
|
7183
|
+
async def cmd_press_hold(selector=None):
|
|
7184
|
+
"""Solve a PerimeterX/HUMAN "Press & Hold" challenge (standalone, opt-in).
|
|
7185
|
+
|
|
7186
|
+
Performs a humanized press->hold(with micro-tremor)->release gesture on the
|
|
7187
|
+
hold target. If no selector is given, auto-locates the px-captcha widget
|
|
7188
|
+
(#px-captcha / [class*="px-captcha"] / "press & hold" text). Prints JSON;
|
|
7189
|
+
exit 0 = solved, 1 = not solved / target not found.
|
|
7190
|
+
|
|
7191
|
+
Usage:
|
|
7192
|
+
cdpilot press-hold # auto-find the px-captcha target
|
|
7193
|
+
cdpilot press-hold "#px-captcha button"
|
|
7194
|
+
"""
|
|
7195
|
+
if not cdp_get("/json/version"):
|
|
7196
|
+
print(json.dumps({'solved': False, 'error': 'no_browser',
|
|
7197
|
+
'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
|
|
7198
|
+
sys.exit(1)
|
|
7199
|
+
ws, _ = get_page_ws()
|
|
7200
|
+
res = await _solve_press_and_hold(ws, target_sel=selector)
|
|
7201
|
+
print(json.dumps(res, ensure_ascii=False))
|
|
7202
|
+
sys.exit(0 if res.get('solved') else 1)
|
|
7203
|
+
|
|
7204
|
+
|
|
7205
|
+
async def cmd_captcha_solve(provider=None):
|
|
7206
|
+
"""Solve the CAPTCHA on the current page (opt-in).
|
|
7207
|
+
|
|
7208
|
+
Providers:
|
|
7209
|
+
amazon-local (default) — OCR Amazon classic image CAPTCHA with the optional
|
|
7210
|
+
`amazoncaptcha` lib (pip install amazoncaptcha).
|
|
7211
|
+
capsolver / 2captcha — BYOK image-to-text via CAPSOLVER_API_KEY /
|
|
7212
|
+
TWOCAPTCHA_API_KEY env vars (stdlib HTTP only).
|
|
7213
|
+
|
|
7214
|
+
Detects Amazon classic CAPTCHA (#captchacharacters + captcha image), solves,
|
|
7215
|
+
fills the input and submits. Prints JSON; exit 0 = solved, 3 = no captcha,
|
|
7216
|
+
1 = error / unsolvable.
|
|
7217
|
+
"""
|
|
7218
|
+
provider = (provider or 'amazon-local').lower()
|
|
7219
|
+
ws, _ = get_page_ws()
|
|
7220
|
+
|
|
7221
|
+
# PerimeterX / HUMAN "Press & Hold" is a behavioural (not token) challenge:
|
|
7222
|
+
# there is no provider to call, the only solution is a real humanized
|
|
7223
|
+
# press->hold->release gesture. Route it automatically whenever detected,
|
|
7224
|
+
# regardless of the requested provider, before the Amazon/BYOK path.
|
|
7225
|
+
pre = await _detect_captcha(ws)
|
|
7226
|
+
if 'perimeterx' in (pre.get('types') or []):
|
|
7227
|
+
res = await _solve_press_and_hold(ws)
|
|
7228
|
+
print(json.dumps(res, ensure_ascii=False))
|
|
7229
|
+
sys.exit(0 if res.get('solved') else 1)
|
|
7230
|
+
|
|
7231
|
+
amz = await _detect_amazon_captcha(ws)
|
|
7232
|
+
if not amz.get('detected'):
|
|
7233
|
+
# Not an Amazon classic captcha — report what (if anything) was found.
|
|
7234
|
+
info = await _detect_captcha(ws)
|
|
7235
|
+
print(json.dumps({
|
|
7236
|
+
'solved': False, 'status': 'no_amazon_captcha',
|
|
7237
|
+
'detected_types': info.get('types', []),
|
|
7238
|
+
}, ensure_ascii=False))
|
|
7239
|
+
sys.exit(3 if not info.get('detected') else 1)
|
|
7240
|
+
|
|
7241
|
+
img_src = amz.get('img_src', '')
|
|
7242
|
+
input_id = amz.get('input_id', 'captchacharacters')
|
|
7243
|
+
img_bytes = await _fetch_image_bytes(ws, img_src)
|
|
7244
|
+
if not img_bytes:
|
|
7245
|
+
print(json.dumps({'solved': False, 'error': 'image_fetch_failed', 'img_src': img_src}, ensure_ascii=False))
|
|
7246
|
+
sys.exit(1)
|
|
7247
|
+
|
|
7248
|
+
solution = None
|
|
7249
|
+
used_provider = None
|
|
7250
|
+
|
|
7251
|
+
if provider in ('amazon-local', 'amazon', 'local'):
|
|
7252
|
+
solution = _solve_amazon_local(img_bytes)
|
|
7253
|
+
used_provider = 'amazon-local'
|
|
7254
|
+
if solution is None:
|
|
7255
|
+
# Distinguish "lib missing" from "lib failed".
|
|
7256
|
+
try:
|
|
7257
|
+
import amazoncaptcha # noqa: F401
|
|
7258
|
+
print(json.dumps({
|
|
7259
|
+
'solved': False, 'status': 'ocr_failed',
|
|
7260
|
+
'provider': 'amazon-local',
|
|
7261
|
+
'hint': 'OCR returned no result; retry or use --provider capsolver',
|
|
7262
|
+
}, ensure_ascii=False))
|
|
7263
|
+
except ImportError:
|
|
7264
|
+
print(json.dumps({
|
|
7265
|
+
'solved': False, 'status': 'amazoncaptcha_not_installed',
|
|
7266
|
+
'hint': 'amazoncaptcha not installed. Run: pip install amazoncaptcha (optional)',
|
|
7267
|
+
}, ensure_ascii=False))
|
|
7268
|
+
sys.exit(1)
|
|
7269
|
+
elif provider in ('capsolver', '2captcha', 'twocaptcha'):
|
|
7270
|
+
res = await _solve_image_byok(img_bytes)
|
|
7271
|
+
if 'error' in res:
|
|
7272
|
+
if res['error'] == 'no_byok_key':
|
|
7273
|
+
print(json.dumps({
|
|
7274
|
+
'solved': False, 'status': 'no_byok_key',
|
|
7275
|
+
'hint': 'Set CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY in the environment',
|
|
7276
|
+
}, ensure_ascii=False))
|
|
7277
|
+
else:
|
|
7278
|
+
print(json.dumps({'solved': False, 'error': res['error']}, ensure_ascii=False))
|
|
7279
|
+
sys.exit(1)
|
|
7280
|
+
solution = res.get('token')
|
|
7281
|
+
used_provider = res.get('provider', provider)
|
|
7282
|
+
else:
|
|
7283
|
+
print(json.dumps({'solved': False, 'error': f'unknown_provider:{provider}'}, ensure_ascii=False))
|
|
7284
|
+
sys.exit(1)
|
|
7285
|
+
|
|
7286
|
+
if not solution:
|
|
7287
|
+
print(json.dumps({'solved': False, 'error': 'empty_solution', 'provider': used_provider}, ensure_ascii=False))
|
|
7288
|
+
sys.exit(1)
|
|
7289
|
+
|
|
7290
|
+
filled = await _fill_amazon_solution(ws, input_id, solution)
|
|
7291
|
+
print(json.dumps({
|
|
7292
|
+
'solved': True, 'solution': solution, 'provider': used_provider,
|
|
7293
|
+
'submitted': filled,
|
|
7294
|
+
}, ensure_ascii=False))
|
|
7295
|
+
sys.exit(0)
|
|
7296
|
+
|
|
7297
|
+
|
|
7298
|
+
# ─── Profile warm-up (reCAPTCHA v3 score aging) ──────────────────────────
|
|
7299
|
+
# New browser profiles get a -0.5 reCAPTCHA v3 trust penalty. Browsing a few
|
|
7300
|
+
# popular, safe sites ages the cookie jar / history so the profile reads as
|
|
7301
|
+
# "established", lifting the v3 score. Opt-in, uses the already-open session.
|
|
7302
|
+
|
|
7303
|
+
WARM_SAFE_SITES = [
|
|
7304
|
+
'https://en.wikipedia.org/wiki/Web_browser',
|
|
7305
|
+
'https://github.com/',
|
|
7306
|
+
'https://stackoverflow.com/',
|
|
7307
|
+
'https://news.ycombinator.com/',
|
|
7308
|
+
'https://www.bbc.com/news',
|
|
7309
|
+
'https://www.reddit.com/',
|
|
7310
|
+
'https://duckduckgo.com/',
|
|
7311
|
+
'https://www.wikipedia.org/',
|
|
7312
|
+
]
|
|
7313
|
+
|
|
7314
|
+
|
|
7315
|
+
async def cmd_profile_warm(minutes=None, sites=None):
|
|
7316
|
+
"""Warm up the current profile by browsing safe popular sites (opt-in).
|
|
7317
|
+
|
|
7318
|
+
Ages cookies/history to boost reCAPTCHA v3 score. Uses the already-open
|
|
7319
|
+
browser session (does not launch). Default ~2 minutes over a hardcoded
|
|
7320
|
+
safe site list with randomized dwell + light scrolling between visits.
|
|
7321
|
+
|
|
7322
|
+
Args:
|
|
7323
|
+
minutes: budget in minutes (default 2, max 30). String from CLI.
|
|
7324
|
+
sites: optional comma-separated override list of URLs.
|
|
7325
|
+
"""
|
|
7326
|
+
import random as _r
|
|
7327
|
+
try:
|
|
7328
|
+
budget_min = float(minutes) if minutes else 2.0
|
|
7329
|
+
except (ValueError, TypeError):
|
|
7330
|
+
budget_min = 2.0
|
|
7331
|
+
budget_min = max(0.5, min(budget_min, 30.0))
|
|
7332
|
+
|
|
7333
|
+
site_list = WARM_SAFE_SITES
|
|
7334
|
+
if sites:
|
|
7335
|
+
if isinstance(sites, str):
|
|
7336
|
+
site_list = [s.strip() for s in sites.split(',') if s.strip()]
|
|
7337
|
+
else:
|
|
7338
|
+
site_list = list(sites)
|
|
7339
|
+
if not site_list:
|
|
7340
|
+
site_list = WARM_SAFE_SITES
|
|
7341
|
+
|
|
7342
|
+
if not cdp_get("/json/version"):
|
|
7343
|
+
print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
|
|
7344
|
+
sys.exit(1)
|
|
7345
|
+
|
|
7346
|
+
ws, _ = get_page_ws()
|
|
7347
|
+
deadline = time.time() + budget_min * 60.0
|
|
7348
|
+
rnd = _r.Random()
|
|
7349
|
+
visited = 0
|
|
7350
|
+
total = len(site_list)
|
|
7351
|
+
|
|
7352
|
+
for idx, url in enumerate(site_list, 1):
|
|
7353
|
+
if time.time() >= deadline:
|
|
7354
|
+
break
|
|
7355
|
+
try:
|
|
7356
|
+
await navigate_collect(ws, url)
|
|
7357
|
+
visited += 1
|
|
7358
|
+
sys.stderr.write(f"🔥 Warming profile: visited {visited}/{total} sites... ({url})\n")
|
|
7359
|
+
sys.stderr.flush()
|
|
7360
|
+
except Exception as e:
|
|
7361
|
+
sys.stderr.write(f"🔥 Warm: skip {url} ({str(e)[:60]})\n")
|
|
7362
|
+
continue
|
|
7363
|
+
|
|
7364
|
+
# Light scroll to look engaged (reuses humanized scroll).
|
|
7365
|
+
try:
|
|
7366
|
+
await _humanize_scroll(ws, rnd.randint(400, 1200))
|
|
7367
|
+
except Exception:
|
|
7368
|
+
pass
|
|
7369
|
+
|
|
7370
|
+
# Random aging delay 5-15s, but never overrun the budget.
|
|
7371
|
+
remaining = deadline - time.time()
|
|
7372
|
+
if remaining <= 0:
|
|
7373
|
+
break
|
|
7374
|
+
await asyncio.sleep(min(rnd.uniform(5, 15), max(0.1, remaining)))
|
|
7375
|
+
|
|
7376
|
+
print(json.dumps({
|
|
7377
|
+
'warmed': True, 'visited': visited, 'sites': total,
|
|
7378
|
+
'budget_minutes': budget_min,
|
|
7379
|
+
}, ensure_ascii=False))
|
|
7380
|
+
|
|
7381
|
+
|
|
7382
|
+
async def cmd_profile_dispatch(args: list) -> None:
|
|
7383
|
+
"""Top-level dispatcher for 'cdpilot profile <subcommand> [args...]'.
|
|
7384
|
+
|
|
7385
|
+
Subcommands:
|
|
7386
|
+
warm [--minutes N] [--sites url1,url2] Warm up profile (reCAPTCHA v3 aging).
|
|
7387
|
+
"""
|
|
7388
|
+
if not args:
|
|
7389
|
+
print(
|
|
7390
|
+
"Usage: cdpilot profile <subcommand>\n"
|
|
7391
|
+
" warm [--minutes N] [--sites url1,url2,...] Age cookies/history to boost reCAPTCHA v3 score",
|
|
7392
|
+
file=sys.stderr,
|
|
7393
|
+
)
|
|
7394
|
+
sys.exit(1)
|
|
7395
|
+
|
|
7396
|
+
sub = args[0].lower()
|
|
7397
|
+
rest = args[1:]
|
|
7398
|
+
|
|
7399
|
+
def _get_flag(flag):
|
|
7400
|
+
for i, a in enumerate(rest):
|
|
7401
|
+
if a == flag and i + 1 < len(rest):
|
|
7402
|
+
return rest[i + 1]
|
|
7403
|
+
if a.startswith(flag + '='):
|
|
7404
|
+
return a.split('=', 1)[1]
|
|
7405
|
+
return None
|
|
7406
|
+
|
|
7407
|
+
if sub == 'warm':
|
|
7408
|
+
await cmd_profile_warm(
|
|
7409
|
+
minutes=_get_flag('--minutes') or _get_flag('-m'),
|
|
7410
|
+
sites=_get_flag('--sites'),
|
|
7411
|
+
)
|
|
7412
|
+
else:
|
|
7413
|
+
print(f"Unknown profile subcommand: {sub}", file=sys.stderr)
|
|
7414
|
+
sys.exit(1)
|
|
7415
|
+
|
|
7416
|
+
|
|
6092
7417
|
# ─── End Captcha Solver Plugin ───────────────────────────────────────────
|
|
6093
7418
|
|
|
6094
7419
|
|
|
@@ -6989,16 +8314,48 @@ async def cmd_smart_click(text):
|
|
|
6989
8314
|
cdpilot smart-click "Learn more"
|
|
6990
8315
|
"""
|
|
6991
8316
|
ws_url, _ = get_page_ws()
|
|
8317
|
+
# Snapshot page targets before the click so a new tab opened by the click
|
|
8318
|
+
# (target=_blank, window.open) can be marked cdpilot-owned afterwards.
|
|
8319
|
+
_pre_click_targets = {t.get("id") for t in (cdp_get("/json") or [])
|
|
8320
|
+
if t.get("type") == "page"}
|
|
8321
|
+
# Locale-aware lowercase so Turkish/German chars round-trip safely.
|
|
8322
|
+
# `toLocaleLowerCase()` (no arg) honors the browser's BCP-47 locale, which
|
|
8323
|
+
# matches what the user sees in the DOM. Python's str.lower() handles
|
|
8324
|
+
# ASCII fine for the *search* term that we marshal across; the in-page
|
|
8325
|
+
# comparison uses the same locale-aware function on both sides.
|
|
6992
8326
|
safe_text = json.dumps(text.lower())
|
|
6993
8327
|
js = f"""
|
|
6994
8328
|
(function() {{
|
|
6995
|
-
|
|
8329
|
+
// Walk into shadow roots so Salesforce Lightning / Polymer / any custom
|
|
8330
|
+
// element with attachShadow({{mode:'open'}}) becomes searchable.
|
|
8331
|
+
// Closed shadow roots are inaccessible by design — nothing we can do
|
|
8332
|
+
// about those without a DevTools-only API.
|
|
8333
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
8334
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
8335
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
8336
|
+
if (el.shadowRoot) {{
|
|
8337
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
8338
|
+
}}
|
|
8339
|
+
}});
|
|
8340
|
+
return found;
|
|
8341
|
+
}}
|
|
8342
|
+
|
|
8343
|
+
function lc(s) {{
|
|
8344
|
+
// Locale-aware lowercase — `toLowerCase()` mishandles Turkish (İ → i̇
|
|
8345
|
+
// combining sequence in some engines) and stays case-sensitive on
|
|
8346
|
+
// ß in older runtimes. `toLocaleLowerCase()` matches the user's
|
|
8347
|
+
// expected mental model: "İletişim" → "iletişim".
|
|
8348
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
8349
|
+
}}
|
|
8350
|
+
|
|
8351
|
+
var search = lc({safe_text}).trim();
|
|
6996
8352
|
var candidates = [];
|
|
8353
|
+
var disabledCount = 0;
|
|
6997
8354
|
|
|
6998
8355
|
// Score: exact > startsWith > includes > partial
|
|
6999
8356
|
function score(str) {{
|
|
7000
8357
|
if (!str) return 0;
|
|
7001
|
-
var s = str
|
|
8358
|
+
var s = lc(str).trim();
|
|
7002
8359
|
if (s === search) return 100;
|
|
7003
8360
|
if (s.startsWith(search)) return 80;
|
|
7004
8361
|
if (s.includes(search)) return 60;
|
|
@@ -7009,13 +8366,13 @@ async def cmd_smart_click(text):
|
|
|
7009
8366
|
return 0;
|
|
7010
8367
|
}}
|
|
7011
8368
|
|
|
7012
|
-
var els = document
|
|
8369
|
+
var els = deepQuerySelectorAll(document,
|
|
7013
8370
|
'a, button, input[type=submit], input[type=button], ' +
|
|
7014
8371
|
'[role=button], [role=link], [role=tab], [role=menuitem], ' +
|
|
7015
8372
|
'summary, label, [onclick], [tabindex]'
|
|
7016
8373
|
);
|
|
7017
8374
|
|
|
7018
|
-
|
|
8375
|
+
els.forEach(function(el) {{
|
|
7019
8376
|
var rect = el.getBoundingClientRect();
|
|
7020
8377
|
if (rect.width === 0 && rect.height === 0) return;
|
|
7021
8378
|
var style = window.getComputedStyle(el);
|
|
@@ -7034,10 +8391,23 @@ async def cmd_smart_click(text):
|
|
|
7034
8391
|
var bestMatch = '';
|
|
7035
8392
|
texts.forEach(function(t) {{
|
|
7036
8393
|
var s = score(t);
|
|
7037
|
-
if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
|
|
8394
|
+
if (s > bestScore) {{ bestScore = s; bestMatch = (t + '').trim().substring(0, 60); }}
|
|
7038
8395
|
}});
|
|
7039
8396
|
|
|
7040
8397
|
if (bestScore > 0) {{
|
|
8398
|
+
// Reject elements that look clickable but won't fire — `<button
|
|
8399
|
+
// disabled>`, `[aria-disabled=true]`, or a disabled ancestor
|
|
8400
|
+
// (fieldset[disabled] propagates to children). Without this the
|
|
8401
|
+
// command reports "Clicked" but nothing happens — silent fail.
|
|
8402
|
+
var isDisabled = (
|
|
8403
|
+
el.disabled === true ||
|
|
8404
|
+
el.getAttribute('aria-disabled') === 'true' ||
|
|
8405
|
+
(typeof el.closest === 'function' && el.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
|
|
8406
|
+
);
|
|
8407
|
+
if (isDisabled) {{
|
|
8408
|
+
disabledCount++;
|
|
8409
|
+
return;
|
|
8410
|
+
}}
|
|
7041
8411
|
candidates.push({{
|
|
7042
8412
|
el: el,
|
|
7043
8413
|
score: bestScore,
|
|
@@ -7049,7 +8419,13 @@ async def cmd_smart_click(text):
|
|
|
7049
8419
|
}}
|
|
7050
8420
|
}});
|
|
7051
8421
|
|
|
7052
|
-
if (candidates.length === 0)
|
|
8422
|
+
if (candidates.length === 0) {{
|
|
8423
|
+
return JSON.stringify({{
|
|
8424
|
+
found: false,
|
|
8425
|
+
allDisabled: disabledCount > 0,
|
|
8426
|
+
disabledCount: disabledCount
|
|
8427
|
+
}});
|
|
8428
|
+
}}
|
|
7053
8429
|
|
|
7054
8430
|
candidates.sort(function(a, b) {{ return b.score - a.score; }});
|
|
7055
8431
|
var best = candidates[0];
|
|
@@ -7078,10 +8454,26 @@ async def cmd_smart_click(text):
|
|
|
7078
8454
|
return
|
|
7079
8455
|
|
|
7080
8456
|
if not data.get("found"):
|
|
7081
|
-
|
|
8457
|
+
if data.get("allDisabled"):
|
|
8458
|
+
# Distinguish "no match" from "match exists but disabled" — the
|
|
8459
|
+
# latter is almost always a timing bug in the caller (form
|
|
8460
|
+
# validation hasn't unlocked the submit button yet).
|
|
8461
|
+
print(f'Error: no enabled element matches "{text}" '
|
|
8462
|
+
f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
|
|
8463
|
+
else:
|
|
8464
|
+
print(f'No element found matching: "{text}"', file=sys.stderr)
|
|
7082
8465
|
sys.exit(1)
|
|
7083
8466
|
|
|
7084
8467
|
await _vfx_ripple(ws_url, data["x"], data["y"])
|
|
8468
|
+
# A click can spawn a new tab (target=_blank). Diff the target list and mark
|
|
8469
|
+
# any newcomer as cdpilot-owned so `close` will clean it up later.
|
|
8470
|
+
try:
|
|
8471
|
+
cdp_cache_invalidate()
|
|
8472
|
+
for t in (cdp_get("/json", no_cache=True) or []):
|
|
8473
|
+
if t.get("type") == "page" and t.get("id") not in _pre_click_targets:
|
|
8474
|
+
_mark_owned_tab(t.get("id"))
|
|
8475
|
+
except Exception:
|
|
8476
|
+
pass
|
|
7085
8477
|
print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
|
|
7086
8478
|
if data.get("alternatives"):
|
|
7087
8479
|
print(f' Also found: {", ".join(data["alternatives"])}')
|
|
@@ -7287,7 +8679,9 @@ async def cmd_dismiss(repeat=None):
|
|
|
7287
8679
|
async def cmd_smart_fill(text, value):
|
|
7288
8680
|
"""Fill input by label/placeholder text — no CSS selector needed.
|
|
7289
8681
|
|
|
7290
|
-
Finds input by: associated label, placeholder, aria-label, name, id match
|
|
8682
|
+
Finds input by: associated label, placeholder, aria-label, name, id match,
|
|
8683
|
+
aria-labelledby, closest [aria-label], and nearby (4-prev) labels for
|
|
8684
|
+
floating-label designs (Material UI, Ant Design, Chakra).
|
|
7291
8685
|
|
|
7292
8686
|
Usage:
|
|
7293
8687
|
cdpilot smart-fill "Email" "test@example.com"
|
|
@@ -7299,44 +8693,97 @@ async def cmd_smart_fill(text, value):
|
|
|
7299
8693
|
safe_value = json.dumps(value)
|
|
7300
8694
|
js = f"""
|
|
7301
8695
|
(function() {{
|
|
7302
|
-
|
|
8696
|
+
// Shadow DOM traversal — same helper as smart-click. Custom-element
|
|
8697
|
+
// form controls (e.g. <sf-input> in Salesforce Lightning) only
|
|
8698
|
+
// expose their <input> via shadow root.
|
|
8699
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
8700
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
8701
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
8702
|
+
if (el.shadowRoot) {{
|
|
8703
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
8704
|
+
}}
|
|
8705
|
+
}});
|
|
8706
|
+
return found;
|
|
8707
|
+
}}
|
|
8708
|
+
|
|
8709
|
+
function lc(s) {{
|
|
8710
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
8711
|
+
}}
|
|
8712
|
+
|
|
8713
|
+
var search = lc({safe_text}).trim();
|
|
7303
8714
|
var value = {safe_value};
|
|
7304
8715
|
var candidates = [];
|
|
7305
8716
|
|
|
7306
8717
|
function score(str) {{
|
|
7307
8718
|
if (!str) return 0;
|
|
7308
|
-
var s = str
|
|
8719
|
+
var s = lc(str).trim();
|
|
7309
8720
|
if (s === search) return 100;
|
|
7310
8721
|
if (s.startsWith(search)) return 80;
|
|
7311
8722
|
if (s.includes(search)) return 60;
|
|
7312
8723
|
return 0;
|
|
7313
8724
|
}}
|
|
7314
8725
|
|
|
7315
|
-
var inputs = document
|
|
7316
|
-
|
|
8726
|
+
var inputs = deepQuerySelectorAll(document,
|
|
8727
|
+
'input, textarea, select, [contenteditable=true]');
|
|
8728
|
+
inputs.forEach(function(el) {{
|
|
7317
8729
|
var rect = el.getBoundingClientRect();
|
|
7318
8730
|
if (rect.width === 0 && rect.height === 0) return;
|
|
7319
8731
|
|
|
7320
8732
|
var scores = [];
|
|
7321
|
-
//
|
|
8733
|
+
// 1. placeholder
|
|
7322
8734
|
scores.push(score(el.getAttribute('placeholder') || ''));
|
|
7323
|
-
//
|
|
8735
|
+
// 2. aria-label
|
|
7324
8736
|
scores.push(score(el.getAttribute('aria-label') || ''));
|
|
7325
|
-
//
|
|
8737
|
+
// 3. name / id
|
|
7326
8738
|
scores.push(score(el.name || ''));
|
|
7327
8739
|
scores.push(score(el.id || ''));
|
|
7328
|
-
//
|
|
8740
|
+
// 4. label[for=id] (classic)
|
|
7329
8741
|
if (el.id) {{
|
|
7330
8742
|
var label = document.querySelector('label[for="' + el.id + '"]');
|
|
7331
8743
|
if (label) scores.push(score(label.textContent || ''));
|
|
7332
8744
|
}}
|
|
7333
|
-
//
|
|
8745
|
+
// 5. parent <label>
|
|
7334
8746
|
var parentLabel = el.closest('label');
|
|
7335
8747
|
if (parentLabel) scores.push(score(parentLabel.textContent || ''));
|
|
7336
|
-
//
|
|
8748
|
+
// 6. immediately-preceding sibling (legacy fallback)
|
|
7337
8749
|
var prev = el.previousElementSibling;
|
|
7338
8750
|
if (prev) scores.push(score(prev.textContent || ''));
|
|
7339
8751
|
|
|
8752
|
+
// 7. aria-labelledby — the input points at one or more IDs whose
|
|
8753
|
+
// textContent forms the accessible name. ARIA spec says space-
|
|
8754
|
+
// separated tokens, in order.
|
|
8755
|
+
var labelledby = el.getAttribute('aria-labelledby');
|
|
8756
|
+
if (labelledby) {{
|
|
8757
|
+
labelledby.split(/\\s+/).forEach(function(id) {{
|
|
8758
|
+
if (!id) return;
|
|
8759
|
+
var ref = document.getElementById(id);
|
|
8760
|
+
if (ref) scores.push(score(ref.textContent || ''));
|
|
8761
|
+
}});
|
|
8762
|
+
}}
|
|
8763
|
+
// 8. closest ancestor with [aria-label] — Material UI / Chakra wrap
|
|
8764
|
+
// the input in a labelled container instead of using <label>.
|
|
8765
|
+
if (typeof el.closest === 'function') {{
|
|
8766
|
+
var aria = el.closest('[aria-label]');
|
|
8767
|
+
if (aria && aria !== el) scores.push(score(aria.getAttribute('aria-label') || ''));
|
|
8768
|
+
}}
|
|
8769
|
+
// 9. nearby label — walk up to 4 previous siblings looking for a
|
|
8770
|
+
// <label> or label-like element. Floating-label designs render
|
|
8771
|
+
// the label as a separate sibling at the parent level.
|
|
8772
|
+
var node = el;
|
|
8773
|
+
for (var depth = 0; depth < 2 && node && node.parentElement; depth++) {{
|
|
8774
|
+
var sib = node.previousElementSibling;
|
|
8775
|
+
for (var i = 0; i < 4 && sib; i++) {{
|
|
8776
|
+
if (sib.tagName === 'LABEL' ||
|
|
8777
|
+
sib.matches && (sib.matches('label, [class*="label" i], [class*="Label"]'))) {{
|
|
8778
|
+
var t = (sib.textContent || '').trim();
|
|
8779
|
+
if (t && t.length < 80) scores.push(score(t));
|
|
8780
|
+
break;
|
|
8781
|
+
}}
|
|
8782
|
+
sib = sib.previousElementSibling;
|
|
8783
|
+
}}
|
|
8784
|
+
node = node.parentElement;
|
|
8785
|
+
}}
|
|
8786
|
+
|
|
7340
8787
|
var bestScore = Math.max.apply(null, scores);
|
|
7341
8788
|
if (bestScore > 0) {{
|
|
7342
8789
|
candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
|
|
@@ -7400,13 +8847,38 @@ async def cmd_smart_select(text, option_text):
|
|
|
7400
8847
|
safe_option = json.dumps(option_text.lower())
|
|
7401
8848
|
js = f"""
|
|
7402
8849
|
(function() {{
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
8850
|
+
// Shadow DOM + locale-aware comparison — same conventions as smart-click
|
|
8851
|
+
// / smart-fill so all three commands behave consistently on Lightning,
|
|
8852
|
+
// Polymer, and Turkish/German content.
|
|
8853
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
8854
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
8855
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
8856
|
+
if (el.shadowRoot) {{
|
|
8857
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
8858
|
+
}}
|
|
8859
|
+
}});
|
|
8860
|
+
return found;
|
|
8861
|
+
}}
|
|
8862
|
+
|
|
8863
|
+
function lc(s) {{
|
|
8864
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
8865
|
+
}}
|
|
8866
|
+
|
|
8867
|
+
var search = lc({safe_text}).trim();
|
|
8868
|
+
var optSearch = lc({safe_option}).trim();
|
|
8869
|
+
var selects = deepQuerySelectorAll(document, 'select');
|
|
7406
8870
|
var best = null;
|
|
7407
8871
|
var bestScore = 0;
|
|
7408
|
-
|
|
7409
|
-
|
|
8872
|
+
var disabledCount = 0;
|
|
8873
|
+
|
|
8874
|
+
selects.forEach(function(sel) {{
|
|
8875
|
+
// Skip disabled selects — same rationale as smart-click: silently
|
|
8876
|
+
// setting .value on a disabled <select> is a no-op and confusing.
|
|
8877
|
+
var isDisabled = (
|
|
8878
|
+
sel.disabled === true ||
|
|
8879
|
+
sel.getAttribute('aria-disabled') === 'true' ||
|
|
8880
|
+
(typeof sel.closest === 'function' && sel.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
|
|
8881
|
+
);
|
|
7410
8882
|
var texts = [
|
|
7411
8883
|
sel.getAttribute('aria-label') || '',
|
|
7412
8884
|
sel.name || '', sel.id || ''
|
|
@@ -7418,21 +8890,30 @@ async def cmd_smart_select(text, option_text):
|
|
|
7418
8890
|
var parent = sel.closest('label');
|
|
7419
8891
|
if (parent) texts.push(parent.textContent || '');
|
|
7420
8892
|
|
|
8893
|
+
var matched = false;
|
|
7421
8894
|
texts.forEach(function(t) {{
|
|
7422
|
-
var s = t
|
|
8895
|
+
var s = lc(t).trim();
|
|
7423
8896
|
var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
|
|
7424
|
-
if (sc >
|
|
8897
|
+
if (sc > 0) matched = true;
|
|
8898
|
+
if (sc > bestScore && !isDisabled) {{ bestScore = sc; best = sel; }}
|
|
7425
8899
|
}});
|
|
8900
|
+
if (matched && isDisabled) disabledCount++;
|
|
7426
8901
|
}});
|
|
7427
8902
|
|
|
7428
|
-
if (!best)
|
|
8903
|
+
if (!best) {{
|
|
8904
|
+
return JSON.stringify({{
|
|
8905
|
+
found: false,
|
|
8906
|
+
allDisabled: disabledCount > 0,
|
|
8907
|
+
disabledCount: disabledCount
|
|
8908
|
+
}});
|
|
8909
|
+
}}
|
|
7429
8910
|
|
|
7430
8911
|
// Find matching option
|
|
7431
8912
|
var options = Array.from(best.options);
|
|
7432
8913
|
var match = options.find(function(o) {{
|
|
7433
|
-
return o.text
|
|
8914
|
+
return lc(o.text).trim() === optSearch;
|
|
7434
8915
|
}}) || options.find(function(o) {{
|
|
7435
|
-
return o.text
|
|
8916
|
+
return lc(o.text).includes(optSearch);
|
|
7436
8917
|
}});
|
|
7437
8918
|
|
|
7438
8919
|
if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
|
|
@@ -7451,7 +8932,11 @@ async def cmd_smart_select(text, option_text):
|
|
|
7451
8932
|
return
|
|
7452
8933
|
|
|
7453
8934
|
if not data.get("found"):
|
|
7454
|
-
|
|
8935
|
+
if data.get("allDisabled"):
|
|
8936
|
+
print(f'Error: no enabled select matches "{text}" '
|
|
8937
|
+
f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
|
|
8938
|
+
else:
|
|
8939
|
+
print(f'No select found matching: "{text}"', file=sys.stderr)
|
|
7455
8940
|
sys.exit(1)
|
|
7456
8941
|
if not data.get("optionFound"):
|
|
7457
8942
|
print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
|
|
@@ -9567,8 +11052,8 @@ class MCPServer:
|
|
|
9567
11052
|
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
|
|
9568
11053
|
{"name": "browser_launch", "description": "Launch an isolated browser instance with Chrome DevTools Protocol enabled. Uses existing Brave/Chrome/Chromium installation — no browser download needed. Creates an isolated profile directory so your personal browser data is never touched.",
|
|
9569
11054
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
9570
|
-
{"name": "browser_close", "description": "
|
|
9571
|
-
"inputSchema": {"type": "object", "properties": {}}},
|
|
11055
|
+
{"name": "browser_close", "description": "Smart close: shut down every tab cdpilot opened during this automation, then close the whole browser application gracefully ONLY if no user-opened tabs remain. If the user has their own tabs open in the same browser, those are left untouched and the browser stays open. Use this to clean up after automation. Set force=true to close the browser even when user tabs remain; set keep_browser=true to only close cdpilot's tabs and never quit the browser.",
|
|
11056
|
+
"inputSchema": {"type": "object", "properties": {"force": {"type": "boolean", "description": "Close the browser application even if user-opened tabs remain.", "default": False}, "keep_browser": {"type": "boolean", "description": "Only close cdpilot's own tabs; never quit the browser.", "default": False}}}},
|
|
9572
11057
|
{"name": "browser_extract", "description": "Extract structured data from elements matching a CSS selector. No LLM needed — pure DOM extraction. Returns text (default), JSON (with tag, text, attrs, href, src), specific attributes, or clean list. Use for scraping tables, lists, links, form values. Limit: 100 elements for JSON, 200 for text.",
|
|
9573
11058
|
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to match elements (e.g. 'table tr', '.product', 'a')"}, "format": {"type": "string", "enum": ["text", "json", "list"], "description": "Output format: 'text' (one per line), 'json' (full structure with attrs), 'list' (numbered)", "default": "text"}}, "required": ["selector"]}},
|
|
9574
11059
|
{"name": "browser_observe", "description": "List all interactive elements on the current page with their available actions (CLICK, FILL, NAVIGATE, TOGGLE, SELECT, SUBMIT, UPLOAD). Like Stagehand observe() but deterministic — no LLM needed. Shows what you CAN DO on the page. Use this to understand page structure before acting.",
|
|
@@ -9577,6 +11062,11 @@ class MCPServer:
|
|
|
9577
11062
|
"inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "Visible text of the element to click (e.g. 'Login', 'Submit Order', 'Learn more')"}}, "required": ["text"]}},
|
|
9578
11063
|
{"name": "browser_dismiss", "description": "Find and click the strongest 'dismiss / continue without account' button on the page. Built-in pattern library covers English + Turkish dismissive phrases ('Stay signed out', 'No thanks', 'Skip', 'Continue without', etc.) and explicitly excludes destructive lookalikes (Delete account, Sign out, Subscribe). Designed for LLM chat sites that gate queries behind a sign-up modal. Pass 'aggressive' or an integer N (max 10) to handle chained modals.",
|
|
9579
11064
|
"inputSchema": {"type": "object", "properties": {"repeat": {"type": "string", "description": "Optional. Number of dismiss attempts (1-10) or 'aggressive' (up to 5 chained). Default: 1.", "default": "1"}}}},
|
|
11065
|
+
{"name": "browser_captcha_solve", "description": "Solve the CAPTCHA on the current page (opt-in). Detects and solves Amazon classic image CAPTCHA (the \"Type the characters you see\" rate-limit page) by OCRing the image, filling the input, and submitting. Default provider 'amazon-local' uses the optional amazoncaptcha library (pip install amazoncaptcha) for offline OCR. BYOK providers 'capsolver' / '2captcha' use image-to-text APIs via CAPSOLVER_API_KEY / TWOCAPTCHA_API_KEY env vars. Returns JSON with solved status. For token-based CAPTCHAs (reCAPTCHA, hCaptcha, Turnstile) use the captcha config/auto CLI instead.",
|
|
11066
|
+
"inputSchema": {"type": "object", "properties": {"provider": {"type": "string", "enum": ["amazon-local", "capsolver", "2captcha"], "description": "Solver provider. Default: amazon-local (offline OCR via optional amazoncaptcha lib).", "default": "amazon-local"}}}},
|
|
11067
|
+
{"name": "browser_press_hold", "description": "Solve a PerimeterX/HUMAN \"Press & Hold\" challenge on the current page (opt-in). PerimeterX is a BEHAVIOURAL challenge -- the user must press AND HOLD a button for several seconds while the detector measures hold duration, natural hand-tremor, and release timing. It is NOT token-based, so there is no provider to call: the only solution is a real humanized press->hold->release gesture, which this tool emits via CDP Input events (Gaussian-randomized ~3-7s hold with +/-1-2px micro-jitter while the button is held). Auto-locates the #px-captcha widget (or pass an explicit selector). Returns JSON with solved status, hold_ms, and attempts. Use this when browser_friction/captcha-check reports a 'perimeterx' type.", "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "Optional CSS selector for the hold target. If omitted, auto-finds the px-captcha button."}}}},
|
|
11068
|
+
{"name": "browser_friction", "description": "Detect the highest anti-bot 'friction' rung on the current page and return the recommended response policy as JSON. Real sites stack defenses incrementally (rate-limit -> CAPTCHA -> login-wall -> SMS/OTP -> hard-block); this reports which rung is currently active. Levels (low->high): none, rate_limited, soft_captcha, login_wall, otp_sms, hard_block. Bilingual (English + Turkish) DOM heuristics. Read-only — never bypasses anything. login_wall/otp_sms/hard_block are flagged for HUMAN handoff (not autonomously solved) for safety/ethics; rate_limited recommends exponential backoff; soft_captcha defers to the captcha tools. Use this for diagnosis before deciding how to proceed on a gated site.",
|
|
11069
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
9580
11070
|
{"name": "browser_smart_fill", "description": "Fill an input by its label or placeholder text — no CSS selector needed. Finds input by associated label, placeholder, aria-label, name, or id. React-compatible value setting. Use when you know WHAT field to fill but not the exact selector.",
|
|
9581
11071
|
"inputSchema": {"type": "object", "properties": {"label": {"type": "string", "description": "Label or placeholder text of the input (e.g. 'Email', 'Password', 'Search')"}, "value": {"type": "string", "description": "Value to fill in the input"}}, "required": ["label", "value"]}},
|
|
9582
11072
|
{"name": "browser_smart_select", "description": "Select a dropdown option by label and option text — no CSS selector needed. Finds the select element by label/name, then selects the matching option. Use for dropdown interactions without knowing selectors.",
|
|
@@ -9607,6 +11097,32 @@ class MCPServer:
|
|
|
9607
11097
|
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
|
|
9608
11098
|
{"name": "browser_screenshot_diff", "description": "Compare two screenshot PNG files byte-by-byte. Returns MATCH if files are identical or DIFF with file sizes if different. Use this for visual regression testing — take a baseline screenshot, perform actions, take another screenshot, then compare.",
|
|
9609
11099
|
"inputSchema": {"type": "object", "properties": {"path1": {"type": "string", "description": "Absolute path to the first (baseline) screenshot PNG"}, "path2": {"type": "string", "description": "Absolute path to the second (current) screenshot PNG"}}, "required": ["path1", "path2"]}},
|
|
11100
|
+
{"name": "browser_watch_start", "description": "Begin a continuous JPEG screencast of the active page to disk. CDP's Page.startScreencast streams frames at 10-30fps so the model can actually SEE animation, mouse cursors, and short visual events that single screenshots miss. Spawns a background daemon that writes JPEGs into a per-project ring buffer (default 5 min retention, 100MB cap). Use this to capture video playback, animated demos, or any time-based UI for later multimodal analysis.",
|
|
11101
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11102
|
+
"url": {"type": "string", "description": "URL or file:// path to load before recording. Pass '-' or omit to attach to the currently-loaded page."},
|
|
11103
|
+
"fps": {"type": "integer", "description": "Frames per second (1-30). Higher = more detail but bigger ring buffer.", "default": 10},
|
|
11104
|
+
"quality": {"type": "integer", "description": "JPEG quality 1-100.", "default": 70},
|
|
11105
|
+
"max_width": {"type": "integer", "description": "Maximum frame width in pixels (shrinks proportionally).", "default": 1280},
|
|
11106
|
+
"retention_s": {"type": "integer", "description": "Seconds to keep frames before eviction.", "default": 300},
|
|
11107
|
+
"disk_cap_mb": {"type": "integer", "description": "Maximum total frame size in MB before oldest-first eviction.", "default": 100},
|
|
11108
|
+
"seek": {"type": "string", "description": "Optional MM:SS or seconds to seek the page's first <video> element to."}
|
|
11109
|
+
}}},
|
|
11110
|
+
{"name": "browser_watch_stop", "description": "Stop the background screencast daemon and (by default) delete the captured frames. Use after you've finished querying so you don't leak disk. Pass keep_frames=true to keep the ring buffer for later inspection.",
|
|
11111
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11112
|
+
"keep_frames": {"type": "boolean", "description": "Keep the captured frames on disk instead of deleting them.", "default": False}
|
|
11113
|
+
}}},
|
|
11114
|
+
{"name": "browser_watch_query", "description": "Return the file paths of JPEG frames captured around a target time window. Frames live on local disk so you can read them and feed them to a multimodal API (vision call) to actually understand what happened. Choose ONE of: at_window (centered on a video time), last (relative to the newest frame), since_last (everything new since the previous query). Returns at most `max` frames (default 16) — when more frames fall in the window we downsample, preferring high-motion moments when Pillow is available.",
|
|
11115
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11116
|
+
"at": {"type": "string", "description": "Video time to center on, MM:SS or seconds (e.g. '1:23'). Measured from screencast start."},
|
|
11117
|
+
"window": {"type": "string", "description": "Width of the window around `at`, e.g. '5s', '500ms'.", "default": "5s"},
|
|
11118
|
+
"last": {"type": "string", "description": "Return frames from the last N seconds (e.g. '5s'). Relative to the newest frame on disk."},
|
|
11119
|
+
"since_last": {"type": "boolean", "description": "Return only frames newer than the previous watch_query call."},
|
|
11120
|
+
"max": {"type": "integer", "description": "Maximum number of frame paths to return (downsampled if window has more).", "default": 16}
|
|
11121
|
+
}}},
|
|
11122
|
+
{"name": "browser_watch_status", "description": "Report the current screencast daemon state: running flag, frame count, total disk usage, oldest/newest timestamps. Use before browser_watch_query to confirm frames are actually being captured.",
|
|
11123
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
11124
|
+
{"name": "browser_mode", "description": "Get or set the three-tier stealth mode (crawl4ai-style escalation). Tiers from lightest to heaviest fingerprint footprint: 'regular' injects NO anti-fingerprint patch (cleanest, fastest, fewest leaks — the default and best for most sites); 'stealth' injects a light patch (navigator.webdriver, chrome.runtime, permissions only — deliberately omits plugin spoofing which leaks); 'undetected' injects the full patch (light + plugin array + WebGL vendor + Worker patch — highest plausibility on naive checks but highest entropy). Omit 'tier' to read the current mode. Effect applies on the next navigation. Escalate to 'undetected' only for hard anti-bot targets.",
|
|
11125
|
+
"inputSchema": {"type": "object", "properties": {"tier": {"type": "string", "enum": ["regular", "stealth", "undetected"], "description": "Tier to set. Omit to get the current tier."}}}},
|
|
9610
11126
|
]
|
|
9611
11127
|
|
|
9612
11128
|
def _handle_request(self, request):
|
|
@@ -9657,12 +11173,15 @@ class MCPServer:
|
|
|
9657
11173
|
"browser_a11y": lambda a: ["a11y"] + ([a["mode"]] if a.get("mode") and a["mode"] != "full" else []),
|
|
9658
11174
|
"browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
|
|
9659
11175
|
"browser_launch": lambda a: ["launch"],
|
|
9660
|
-
"browser_close": lambda a: ["close"],
|
|
11176
|
+
"browser_close": lambda a: ["close"] + (["--force"] if a.get("force") else []) + (["--keep"] if a.get("keep_browser") else []),
|
|
9661
11177
|
"browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
|
|
9662
11178
|
"browser_observe": lambda a: ["observe"],
|
|
9663
11179
|
"browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
|
|
9664
11180
|
"browser_dismiss": lambda a: ["dismiss"] + ([str(a["repeat"])] if a.get("repeat") else []),
|
|
9665
11181
|
"browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
|
|
11182
|
+
"browser_captcha_solve": lambda a: ["captcha-solve"] + ([f"--provider={a['provider']}"] if a.get("provider") else []),
|
|
11183
|
+
"browser_press_hold": lambda a: ["press-hold"] + ([a["selector"]] if a.get("selector") else []),
|
|
11184
|
+
"browser_friction": lambda a: ["friction"],
|
|
9666
11185
|
"browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
|
|
9667
11186
|
"browser_describe": lambda a: ["describe"],
|
|
9668
11187
|
"browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
|
|
@@ -9677,6 +11196,11 @@ class MCPServer:
|
|
|
9677
11196
|
"browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
|
|
9678
11197
|
"browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
|
|
9679
11198
|
"browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
|
|
11199
|
+
"browser_watch_start": lambda a: ["watch", "start"] + ([a["url"]] if a.get("url") else []) + ([f"--fps={a['fps']}"] if a.get("fps") else []) + ([f"--quality={a['quality']}"] if a.get("quality") else []) + ([f"--max-width={a['max_width']}"] if a.get("max_width") else []) + ([f"--retention={a['retention_s']}"] if a.get("retention_s") else []) + ([f"--disk-cap={a['disk_cap_mb']}"] if a.get("disk_cap_mb") else []) + ([f"--seek={a['seek']}"] if a.get("seek") else []),
|
|
11200
|
+
"browser_watch_stop": lambda a: ["watch", "stop"] + (["--keep-frames"] if a.get("keep_frames") else []),
|
|
11201
|
+
"browser_watch_query": lambda a: ["watch", "query"] + ([f"--at={a['at']}"] if a.get("at") else []) + ([f"--window={a['window']}"] if a.get("window") else []) + ([f"--last={a['last']}"] if a.get("last") else []) + (["--since-last"] if a.get("since_last") else []) + ([f"--max={a['max']}"] if a.get("max") else []),
|
|
11202
|
+
"browser_watch_status": lambda a: ["watch", "status"],
|
|
11203
|
+
"browser_mode": lambda a: ["mode"] + ([a["tier"]] if a.get("tier") else []),
|
|
9680
11204
|
}
|
|
9681
11205
|
if tool_name not in tool_map:
|
|
9682
11206
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
|
|
@@ -10409,25 +11933,848 @@ def _dispatch_blog_cmd(args):
|
|
|
10409
11933
|
# ─── End Blog Publish Namespace ───────────────────────────────────────────────
|
|
10410
11934
|
|
|
10411
11935
|
|
|
10412
|
-
# ───
|
|
11936
|
+
# ─── cdpilot watch — continuous screencast for AI video understanding ─────────
|
|
11937
|
+
#
|
|
11938
|
+
# Why this exists: still screenshots taken at command-rate intervals (~1s)
|
|
11939
|
+
# cannot capture animation, mouse cursors, scroll dynamics, or short visual
|
|
11940
|
+
# events ("kedi sola mı sağa mı koştu?"). CDP's built-in Page.startScreencast
|
|
11941
|
+
# streams JPEG frames at 10-30fps directly from the renderer. We tee the
|
|
11942
|
+
# stream onto a disk ring buffer so AI orchestrators (Claude, etc.) can query
|
|
11943
|
+
# any time window after the fact and get real frames to feed into a
|
|
11944
|
+
# multimodal API. Two-process design: a long-lived daemon owns the WS and
|
|
11945
|
+
# writes frames; foreground `query/status/stop` commands only read disk.
|
|
10413
11946
|
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
11947
|
+
WATCH_DEFAULT_FPS = 10
|
|
11948
|
+
WATCH_DEFAULT_QUALITY = 70
|
|
11949
|
+
WATCH_DEFAULT_MAX_WIDTH = 1280
|
|
11950
|
+
WATCH_DEFAULT_RETENTION_S = 300 # 5 minutes
|
|
11951
|
+
WATCH_DEFAULT_DISK_CAP_MB = 100
|
|
11952
|
+
WATCH_DAEMON_FLAG = '--_watch-daemon' # hidden — used only for re-entrant fork
|
|
10418
11953
|
|
|
10419
|
-
cmd = sys.argv[1]
|
|
10420
|
-
args = sys.argv[2:]
|
|
10421
11954
|
|
|
10422
|
-
|
|
10423
|
-
|
|
10424
|
-
|
|
10425
|
-
|
|
10426
|
-
|
|
11955
|
+
def _watch_dir():
|
|
11956
|
+
"""Return per-project watch directory (state + frames + index)."""
|
|
11957
|
+
pid = PROJECT_ID or _get_project_id()
|
|
11958
|
+
return os.path.join(CDPILOT_HOME, 'projects', pid, 'watch')
|
|
11959
|
+
|
|
11960
|
+
|
|
11961
|
+
def _watch_frames_dir():
|
|
11962
|
+
return os.path.join(_watch_dir(), 'frames')
|
|
11963
|
+
|
|
11964
|
+
|
|
11965
|
+
def _watch_state_path():
|
|
11966
|
+
return os.path.join(_watch_dir(), 'state.json')
|
|
11967
|
+
|
|
11968
|
+
|
|
11969
|
+
def _watch_index_path():
|
|
11970
|
+
return os.path.join(_watch_dir(), 'index.jsonl')
|
|
11971
|
+
|
|
11972
|
+
|
|
11973
|
+
def _watch_log_path():
|
|
11974
|
+
return os.path.join(_watch_dir(), 'daemon.log')
|
|
11975
|
+
|
|
11976
|
+
|
|
11977
|
+
def _watch_load_state():
|
|
11978
|
+
try:
|
|
11979
|
+
with open(_watch_state_path()) as f:
|
|
11980
|
+
return json.load(f)
|
|
11981
|
+
except (OSError, ValueError):
|
|
11982
|
+
return None
|
|
11983
|
+
|
|
11984
|
+
|
|
11985
|
+
def _watch_save_state(state):
|
|
11986
|
+
os.makedirs(_watch_dir(), exist_ok=True)
|
|
11987
|
+
tmp = _watch_state_path() + '.tmp'
|
|
11988
|
+
with open(tmp, 'w') as f:
|
|
11989
|
+
json.dump(state, f, indent=2)
|
|
11990
|
+
os.replace(tmp, _watch_state_path())
|
|
11991
|
+
|
|
11992
|
+
|
|
11993
|
+
def _watch_clear_state():
|
|
11994
|
+
for p in (_watch_state_path(), _watch_index_path()):
|
|
11995
|
+
try:
|
|
11996
|
+
os.remove(p)
|
|
11997
|
+
except OSError:
|
|
11998
|
+
pass
|
|
11999
|
+
|
|
12000
|
+
|
|
12001
|
+
def _watch_pid_alive(pid):
|
|
12002
|
+
if not pid:
|
|
12003
|
+
return False
|
|
12004
|
+
try:
|
|
12005
|
+
os.kill(int(pid), 0)
|
|
12006
|
+
return True
|
|
12007
|
+
except (OSError, ValueError):
|
|
12008
|
+
return False
|
|
12009
|
+
|
|
12010
|
+
|
|
12011
|
+
def _watch_parse_timecode(spec):
|
|
12012
|
+
"""Parse '1:23', '83', '1:23.5' into seconds (float). Returns None on failure."""
|
|
12013
|
+
if spec is None:
|
|
12014
|
+
return None
|
|
12015
|
+
s = str(spec).strip()
|
|
12016
|
+
if not s:
|
|
12017
|
+
return None
|
|
12018
|
+
try:
|
|
12019
|
+
if ':' in s:
|
|
12020
|
+
mm, ss = s.split(':', 1)
|
|
12021
|
+
return float(mm) * 60 + float(ss)
|
|
12022
|
+
return float(s)
|
|
12023
|
+
except ValueError:
|
|
12024
|
+
return None
|
|
12025
|
+
|
|
12026
|
+
|
|
12027
|
+
def _watch_parse_window(spec):
|
|
12028
|
+
"""Parse '5s', '2.5', '500ms' into seconds (float). Returns 5.0 on failure."""
|
|
12029
|
+
if spec is None:
|
|
12030
|
+
return 5.0
|
|
12031
|
+
s = str(spec).strip().lower()
|
|
12032
|
+
if not s:
|
|
12033
|
+
return 5.0
|
|
12034
|
+
try:
|
|
12035
|
+
if s.endswith('ms'):
|
|
12036
|
+
return float(s[:-2]) / 1000.0
|
|
12037
|
+
if s.endswith('s'):
|
|
12038
|
+
return float(s[:-1])
|
|
12039
|
+
return float(s)
|
|
12040
|
+
except ValueError:
|
|
12041
|
+
return 5.0
|
|
12042
|
+
|
|
12043
|
+
|
|
12044
|
+
def _watch_evict(retention_s, disk_cap_bytes):
|
|
12045
|
+
"""Drop frames older than retention OR over disk cap (oldest-first).
|
|
12046
|
+
|
|
12047
|
+
Returns (removed_count, bytes_freed).
|
|
12048
|
+
"""
|
|
12049
|
+
fdir = _watch_frames_dir()
|
|
12050
|
+
if not os.path.isdir(fdir):
|
|
12051
|
+
return 0, 0
|
|
12052
|
+
now_ms = int(time.time() * 1000)
|
|
12053
|
+
cutoff_ms = now_ms - int(retention_s * 1000)
|
|
12054
|
+
|
|
12055
|
+
files = []
|
|
12056
|
+
for name in os.listdir(fdir):
|
|
12057
|
+
if not name.endswith('.jpg'):
|
|
12058
|
+
continue
|
|
12059
|
+
try:
|
|
12060
|
+
ts_ms = int(name[:-4])
|
|
12061
|
+
except ValueError:
|
|
12062
|
+
continue
|
|
12063
|
+
path = os.path.join(fdir, name)
|
|
12064
|
+
try:
|
|
12065
|
+
size = os.path.getsize(path)
|
|
12066
|
+
except OSError:
|
|
12067
|
+
continue
|
|
12068
|
+
files.append((ts_ms, path, size))
|
|
12069
|
+
files.sort() # oldest first
|
|
12070
|
+
|
|
12071
|
+
removed = 0
|
|
12072
|
+
freed = 0
|
|
12073
|
+
# Age-based eviction
|
|
12074
|
+
keep = []
|
|
12075
|
+
for ts_ms, path, size in files:
|
|
12076
|
+
if ts_ms < cutoff_ms:
|
|
12077
|
+
try:
|
|
12078
|
+
os.remove(path)
|
|
12079
|
+
removed += 1
|
|
12080
|
+
freed += size
|
|
12081
|
+
except OSError:
|
|
12082
|
+
pass
|
|
12083
|
+
else:
|
|
12084
|
+
keep.append((ts_ms, path, size))
|
|
12085
|
+
|
|
12086
|
+
# Disk-cap eviction (drop oldest until under cap)
|
|
12087
|
+
total = sum(s for _, _, s in keep)
|
|
12088
|
+
while keep and total > disk_cap_bytes:
|
|
12089
|
+
ts_ms, path, size = keep.pop(0)
|
|
12090
|
+
try:
|
|
12091
|
+
os.remove(path)
|
|
12092
|
+
removed += 1
|
|
12093
|
+
freed += size
|
|
12094
|
+
total -= size
|
|
12095
|
+
except OSError:
|
|
12096
|
+
pass
|
|
12097
|
+
|
|
12098
|
+
return removed, freed
|
|
12099
|
+
|
|
12100
|
+
|
|
12101
|
+
def _watch_list_frames():
|
|
12102
|
+
"""Scan the ring buffer dir, return sorted list of (ts_ms, path)."""
|
|
12103
|
+
fdir = _watch_frames_dir()
|
|
12104
|
+
out = []
|
|
12105
|
+
if not os.path.isdir(fdir):
|
|
12106
|
+
return out
|
|
12107
|
+
for name in os.listdir(fdir):
|
|
12108
|
+
if not name.endswith('.jpg'):
|
|
12109
|
+
continue
|
|
12110
|
+
try:
|
|
12111
|
+
ts_ms = int(name[:-4])
|
|
12112
|
+
except ValueError:
|
|
12113
|
+
continue
|
|
12114
|
+
out.append((ts_ms, os.path.join(fdir, name)))
|
|
12115
|
+
out.sort()
|
|
12116
|
+
return out
|
|
12117
|
+
|
|
12118
|
+
|
|
12119
|
+
def _watch_frame_diff(a_path, b_path):
|
|
12120
|
+
"""Mean absolute pixel difference (0.0..1.0). Returns None if PIL missing.
|
|
12121
|
+
|
|
12122
|
+
Graceful degradation: if PIL/Pillow is not installed we return None and
|
|
12123
|
+
callers fall back to uniform frame picking.
|
|
12124
|
+
"""
|
|
12125
|
+
try:
|
|
12126
|
+
from PIL import Image, ImageChops, ImageStat # type: ignore
|
|
12127
|
+
except ImportError:
|
|
12128
|
+
return None
|
|
12129
|
+
try:
|
|
12130
|
+
with Image.open(a_path) as a_img, Image.open(b_path) as b_img:
|
|
12131
|
+
a_g = a_img.convert('L').resize((160, 90))
|
|
12132
|
+
b_g = b_img.convert('L').resize((160, 90))
|
|
12133
|
+
diff = ImageChops.difference(a_g, b_g)
|
|
12134
|
+
stat = ImageStat.Stat(diff)
|
|
12135
|
+
return float(stat.mean[0]) / 255.0
|
|
12136
|
+
except Exception:
|
|
12137
|
+
return None
|
|
12138
|
+
|
|
12139
|
+
|
|
12140
|
+
# ─── watch daemon (the actual screencast consumer) ───
|
|
12141
|
+
|
|
12142
|
+
async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
|
|
12143
|
+
disk_cap_bytes, seek_s):
|
|
12144
|
+
"""Long-lived process: open WS, subscribe to screencastFrame, write JPEGs.
|
|
12145
|
+
|
|
12146
|
+
Runs until SIGTERM / parent kill. Each frame:
|
|
12147
|
+
1. base64-decode payload
|
|
12148
|
+
2. write to frames/<unix_ms>.jpg
|
|
12149
|
+
3. ACK with Page.screencastFrameAck (required, else stream stalls)
|
|
12150
|
+
4. periodically evict (every ~2s)
|
|
12151
|
+
"""
|
|
12152
|
+
import websockets
|
|
12153
|
+
|
|
12154
|
+
fdir = _watch_frames_dir()
|
|
12155
|
+
os.makedirs(fdir, exist_ok=True)
|
|
12156
|
+
|
|
12157
|
+
# Launch browser if needed
|
|
12158
|
+
if not cdp_get("/json/version"):
|
|
12159
|
+
cmd_launch()
|
|
12160
|
+
|
|
12161
|
+
ws_url, _ = get_page_ws()
|
|
12162
|
+
|
|
12163
|
+
# Navigate first (so the page exists before we start screencasting).
|
|
12164
|
+
# If url is empty we just attach to whatever is currently loaded.
|
|
12165
|
+
if url:
|
|
12166
|
+
try:
|
|
12167
|
+
await navigate_collect(ws_url, url, glow=False)
|
|
12168
|
+
except Exception as e:
|
|
12169
|
+
sys.stderr.write(f"watch: navigate failed: {e}\n")
|
|
12170
|
+
|
|
12171
|
+
# Try to coax a <video> element into autoplay + (optional) seek.
|
|
12172
|
+
# Best-effort — silent if the page has no video.
|
|
12173
|
+
autoplay_js = (
|
|
12174
|
+
"(()=>{const v=document.querySelector('video');"
|
|
12175
|
+
"if(!v) return {ok:false,reason:'no-video'};"
|
|
12176
|
+
"try{v.muted=true;v.playsInline=true;"
|
|
12177
|
+
+ (f"v.currentTime={float(seek_s)};" if seek_s is not None else "")
|
|
12178
|
+
+ "const p=v.play();if(p&&p.catch)p.catch(()=>{});"
|
|
12179
|
+
"return {ok:true,duration:v.duration||0,currentTime:v.currentTime};}"
|
|
12180
|
+
"catch(e){return {ok:false,reason:String(e)};}})()"
|
|
12181
|
+
)
|
|
12182
|
+
try:
|
|
12183
|
+
await cdp_send(ws_url, [(81, "Runtime.evaluate", {
|
|
12184
|
+
"expression": autoplay_js, "returnByValue": True,
|
|
12185
|
+
})])
|
|
12186
|
+
except Exception:
|
|
12187
|
+
pass
|
|
12188
|
+
|
|
12189
|
+
# everyNthFrame: CDP samples at ~60fps native; nth=6 → ~10fps.
|
|
12190
|
+
every_nth = max(1, int(round(60 / max(1, fps))))
|
|
12191
|
+
|
|
12192
|
+
# We bypass the pooled cdp_send for the screencast loop because we need
|
|
12193
|
+
# to consume *unsolicited* event frames (Page.screencastFrame) for the
|
|
12194
|
+
# entire daemon lifetime. The pool model assumes request/response.
|
|
12195
|
+
async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
|
|
12196
|
+
await ws.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
|
|
12197
|
+
await ws.send(json.dumps({
|
|
12198
|
+
"id": 2, "method": "Page.startScreencast",
|
|
12199
|
+
"params": {
|
|
12200
|
+
"format": "jpeg",
|
|
12201
|
+
"quality": int(quality),
|
|
12202
|
+
"maxWidth": int(max_width),
|
|
12203
|
+
"everyNthFrame": every_nth,
|
|
12204
|
+
},
|
|
12205
|
+
}))
|
|
12206
|
+
|
|
12207
|
+
last_evict = time.time()
|
|
12208
|
+
frame_count = 0
|
|
12209
|
+
# Update state file with confirmed start so `status` shows "running"
|
|
12210
|
+
st = _watch_load_state() or {}
|
|
12211
|
+
st['screencast_started_at_ms'] = int(time.time() * 1000)
|
|
12212
|
+
_watch_save_state(st)
|
|
12213
|
+
|
|
12214
|
+
while True:
|
|
12215
|
+
try:
|
|
12216
|
+
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
|
12217
|
+
except asyncio.TimeoutError:
|
|
12218
|
+
# Periodic eviction even if no frames arrive (e.g. page paused)
|
|
12219
|
+
if time.time() - last_evict > 2.0:
|
|
12220
|
+
_watch_evict(retention_s, disk_cap_bytes)
|
|
12221
|
+
last_evict = time.time()
|
|
12222
|
+
continue
|
|
12223
|
+
try:
|
|
12224
|
+
data = json.loads(resp)
|
|
12225
|
+
except ValueError:
|
|
12226
|
+
continue
|
|
12227
|
+
if data.get("method") != "Page.screencastFrame":
|
|
12228
|
+
continue
|
|
12229
|
+
params = data.get("params", {})
|
|
12230
|
+
session_id = params.get("sessionId")
|
|
12231
|
+
b64 = params.get("data", "")
|
|
12232
|
+
ts_ms = int(time.time() * 1000)
|
|
12233
|
+
|
|
12234
|
+
try:
|
|
12235
|
+
raw = base64.b64decode(b64)
|
|
12236
|
+
except Exception:
|
|
12237
|
+
raw = b""
|
|
12238
|
+
if raw:
|
|
12239
|
+
fpath = os.path.join(fdir, f"{ts_ms}.jpg")
|
|
12240
|
+
try:
|
|
12241
|
+
with open(fpath, 'wb') as f:
|
|
12242
|
+
f.write(raw)
|
|
12243
|
+
# Append to index (best-effort; missing entries are fine,
|
|
12244
|
+
# query falls back to scanning the dir directly)
|
|
12245
|
+
try:
|
|
12246
|
+
with open(_watch_index_path(), 'a') as idx:
|
|
12247
|
+
idx.write(json.dumps({
|
|
12248
|
+
"ts_ms": ts_ms,
|
|
12249
|
+
"filename": f"{ts_ms}.jpg",
|
|
12250
|
+
"size": len(raw),
|
|
12251
|
+
}) + "\n")
|
|
12252
|
+
except OSError:
|
|
12253
|
+
pass
|
|
12254
|
+
frame_count += 1
|
|
12255
|
+
except OSError as e:
|
|
12256
|
+
sys.stderr.write(f"watch: write failed: {e}\n")
|
|
12257
|
+
|
|
12258
|
+
# ACK is REQUIRED — CDP stops sending frames until acknowledged.
|
|
12259
|
+
try:
|
|
12260
|
+
await ws.send(json.dumps({
|
|
12261
|
+
"id": 1000 + (frame_count % 10000),
|
|
12262
|
+
"method": "Page.screencastFrameAck",
|
|
12263
|
+
"params": {"sessionId": session_id},
|
|
12264
|
+
}))
|
|
12265
|
+
except Exception:
|
|
12266
|
+
break
|
|
12267
|
+
|
|
12268
|
+
# Periodic eviction (every ~2s)
|
|
12269
|
+
if time.time() - last_evict > 2.0:
|
|
12270
|
+
_watch_evict(retention_s, disk_cap_bytes)
|
|
12271
|
+
last_evict = time.time()
|
|
12272
|
+
|
|
12273
|
+
|
|
12274
|
+
def _cmd_watch_daemon_entry():
|
|
12275
|
+
"""Internal: re-entrant entry point when called with --_watch-daemon flag.
|
|
12276
|
+
|
|
12277
|
+
Reads parameters from state file, runs the async screencast loop forever.
|
|
12278
|
+
Logs go to daemon.log so the foreground process can stay clean.
|
|
12279
|
+
"""
|
|
12280
|
+
state = _watch_load_state() or {}
|
|
12281
|
+
url = state.get('url') or ''
|
|
12282
|
+
fps = int(state.get('fps') or WATCH_DEFAULT_FPS)
|
|
12283
|
+
quality = int(state.get('quality') or WATCH_DEFAULT_QUALITY)
|
|
12284
|
+
max_width = int(state.get('max_width') or WATCH_DEFAULT_MAX_WIDTH)
|
|
12285
|
+
retention_s = float(state.get('retention_s') or WATCH_DEFAULT_RETENTION_S)
|
|
12286
|
+
disk_cap_bytes = int(state.get('disk_cap_bytes') or WATCH_DEFAULT_DISK_CAP_MB * 1024 * 1024)
|
|
12287
|
+
seek_s = state.get('seek_s')
|
|
12288
|
+
|
|
12289
|
+
state['pid'] = os.getpid()
|
|
12290
|
+
state['daemon_started_at_ms'] = int(time.time() * 1000)
|
|
12291
|
+
_watch_save_state(state)
|
|
12292
|
+
|
|
12293
|
+
try:
|
|
12294
|
+
asyncio.run(_watch_daemon_run(
|
|
12295
|
+
url, fps, quality, max_width, retention_s, disk_cap_bytes, seek_s
|
|
12296
|
+
))
|
|
12297
|
+
except KeyboardInterrupt:
|
|
12298
|
+
pass
|
|
12299
|
+
except Exception as e:
|
|
12300
|
+
try:
|
|
12301
|
+
with open(_watch_log_path(), 'a') as f:
|
|
12302
|
+
f.write(f"[{datetime.datetime.utcnow().isoformat()}] daemon crash: {e}\n")
|
|
12303
|
+
except OSError:
|
|
12304
|
+
pass
|
|
12305
|
+
|
|
12306
|
+
|
|
12307
|
+
def cmd_watch_start(*args):
|
|
12308
|
+
"""Start screencast capture in a background daemon.
|
|
12309
|
+
|
|
12310
|
+
cdpilot watch start <url|file://path|-> [--fps=10] [--quality=70]
|
|
12311
|
+
[--max-width=1280]
|
|
12312
|
+
[--retention=300]
|
|
12313
|
+
[--disk-cap=100]
|
|
12314
|
+
[--seek=1:23]
|
|
12315
|
+
|
|
12316
|
+
- URL may be omitted to attach to the currently-loaded page.
|
|
12317
|
+
- `-` as URL also means "attach, don't navigate".
|
|
12318
|
+
- Idempotent: if a daemon for this project is already running it is
|
|
12319
|
+
stopped first (we never want two screencasts on the same target).
|
|
12320
|
+
"""
|
|
12321
|
+
# Stop any prior daemon — two screencasts on one target would race.
|
|
12322
|
+
prev = _watch_load_state()
|
|
12323
|
+
if prev and _watch_pid_alive(prev.get('pid')):
|
|
12324
|
+
try:
|
|
12325
|
+
os.kill(int(prev['pid']), 15)
|
|
12326
|
+
except OSError:
|
|
12327
|
+
pass
|
|
12328
|
+
# brief wait so the WS releases
|
|
12329
|
+
for _ in range(20):
|
|
12330
|
+
if not _watch_pid_alive(prev.get('pid')):
|
|
12331
|
+
break
|
|
12332
|
+
time.sleep(0.05)
|
|
12333
|
+
|
|
12334
|
+
# Wipe ring buffer + index from any prior run (same project, different video)
|
|
12335
|
+
fdir = _watch_frames_dir()
|
|
12336
|
+
if os.path.isdir(fdir):
|
|
12337
|
+
for name in os.listdir(fdir):
|
|
12338
|
+
if name.endswith('.jpg'):
|
|
12339
|
+
try:
|
|
12340
|
+
os.remove(os.path.join(fdir, name))
|
|
12341
|
+
except OSError:
|
|
12342
|
+
pass
|
|
12343
|
+
try:
|
|
12344
|
+
os.remove(_watch_index_path())
|
|
12345
|
+
except OSError:
|
|
12346
|
+
pass
|
|
12347
|
+
|
|
12348
|
+
# Parse args
|
|
12349
|
+
url = ''
|
|
12350
|
+
fps = WATCH_DEFAULT_FPS
|
|
12351
|
+
quality = WATCH_DEFAULT_QUALITY
|
|
12352
|
+
max_width = WATCH_DEFAULT_MAX_WIDTH
|
|
12353
|
+
retention_s = WATCH_DEFAULT_RETENTION_S
|
|
12354
|
+
disk_cap_mb = WATCH_DEFAULT_DISK_CAP_MB
|
|
12355
|
+
seek_s = None
|
|
12356
|
+
|
|
12357
|
+
positional = []
|
|
12358
|
+
for a in args:
|
|
12359
|
+
if a.startswith('--fps='):
|
|
12360
|
+
try:
|
|
12361
|
+
fps = max(1, min(30, int(a.split('=', 1)[1])))
|
|
12362
|
+
except ValueError:
|
|
12363
|
+
pass
|
|
12364
|
+
elif a.startswith('--quality='):
|
|
12365
|
+
try:
|
|
12366
|
+
quality = max(1, min(100, int(a.split('=', 1)[1])))
|
|
12367
|
+
except ValueError:
|
|
12368
|
+
pass
|
|
12369
|
+
elif a.startswith('--max-width='):
|
|
12370
|
+
try:
|
|
12371
|
+
max_width = max(160, min(3840, int(a.split('=', 1)[1])))
|
|
12372
|
+
except ValueError:
|
|
12373
|
+
pass
|
|
12374
|
+
elif a.startswith('--retention='):
|
|
12375
|
+
try:
|
|
12376
|
+
retention_s = max(5, int(a.split('=', 1)[1]))
|
|
12377
|
+
except ValueError:
|
|
12378
|
+
pass
|
|
12379
|
+
elif a.startswith('--disk-cap='):
|
|
12380
|
+
try:
|
|
12381
|
+
disk_cap_mb = max(1, int(a.split('=', 1)[1]))
|
|
12382
|
+
except ValueError:
|
|
12383
|
+
pass
|
|
12384
|
+
elif a.startswith('--seek='):
|
|
12385
|
+
seek_s = _watch_parse_timecode(a.split('=', 1)[1])
|
|
12386
|
+
elif not a.startswith('--'):
|
|
12387
|
+
positional.append(a)
|
|
12388
|
+
|
|
12389
|
+
if positional:
|
|
12390
|
+
url = positional[0]
|
|
12391
|
+
if url == '-':
|
|
12392
|
+
url = ''
|
|
12393
|
+
|
|
12394
|
+
os.makedirs(_watch_frames_dir(), exist_ok=True)
|
|
12395
|
+
state = {
|
|
12396
|
+
'url': url,
|
|
12397
|
+
'fps': fps,
|
|
12398
|
+
'quality': quality,
|
|
12399
|
+
'max_width': max_width,
|
|
12400
|
+
'retention_s': retention_s,
|
|
12401
|
+
'disk_cap_bytes': disk_cap_mb * 1024 * 1024,
|
|
12402
|
+
'seek_s': seek_s,
|
|
12403
|
+
'started_at_ms': int(time.time() * 1000),
|
|
12404
|
+
'project_id': PROJECT_ID,
|
|
12405
|
+
'pid': None,
|
|
12406
|
+
}
|
|
12407
|
+
_watch_save_state(state)
|
|
12408
|
+
|
|
12409
|
+
# Fork the daemon. Use the same python + this script + the hidden flag.
|
|
12410
|
+
script = os.path.abspath(__file__)
|
|
12411
|
+
log_f = None
|
|
12412
|
+
try:
|
|
12413
|
+
log_f = open(_watch_log_path(), 'a')
|
|
12414
|
+
except OSError:
|
|
12415
|
+
log_f = subprocess.DEVNULL
|
|
12416
|
+
|
|
12417
|
+
env = os.environ.copy()
|
|
12418
|
+
env['CDPILOT_PROJECT_ID'] = PROJECT_ID or _get_project_id()
|
|
12419
|
+
# The daemon must use the same CDP port (same project's browser)
|
|
12420
|
+
env['CDP_PORT'] = str(CDP_PORT)
|
|
12421
|
+
|
|
12422
|
+
proc = subprocess.Popen(
|
|
12423
|
+
[sys.executable, script, WATCH_DAEMON_FLAG],
|
|
12424
|
+
stdout=log_f, stderr=log_f,
|
|
12425
|
+
env=env,
|
|
12426
|
+
start_new_session=True, # detach from this terminal's process group
|
|
12427
|
+
)
|
|
12428
|
+
state['pid'] = proc.pid
|
|
12429
|
+
_watch_save_state(state)
|
|
12430
|
+
|
|
12431
|
+
# Wait briefly for the daemon to confirm screencast start
|
|
12432
|
+
confirmed = False
|
|
12433
|
+
for _ in range(40): # up to ~2s
|
|
12434
|
+
time.sleep(0.05)
|
|
12435
|
+
st = _watch_load_state() or {}
|
|
12436
|
+
if st.get('screencast_started_at_ms'):
|
|
12437
|
+
confirmed = True
|
|
12438
|
+
break
|
|
12439
|
+
if not _watch_pid_alive(proc.pid):
|
|
12440
|
+
break
|
|
12441
|
+
|
|
12442
|
+
out = {
|
|
12443
|
+
'ok': confirmed or _watch_pid_alive(proc.pid),
|
|
12444
|
+
'pid': proc.pid,
|
|
12445
|
+
'url': url or '(attach)',
|
|
12446
|
+
'fps': fps,
|
|
12447
|
+
'quality': quality,
|
|
12448
|
+
'max_width': max_width,
|
|
12449
|
+
'retention_s': retention_s,
|
|
12450
|
+
'disk_cap_mb': disk_cap_mb,
|
|
12451
|
+
'frames_dir': _watch_frames_dir(),
|
|
12452
|
+
'state_path': _watch_state_path(),
|
|
12453
|
+
}
|
|
12454
|
+
print(json.dumps(out, indent=2))
|
|
12455
|
+
if not out['ok']:
|
|
12456
|
+
sys.exit(1)
|
|
12457
|
+
|
|
12458
|
+
|
|
12459
|
+
def cmd_watch_stop(*args):
|
|
12460
|
+
"""Stop the watch daemon and optionally cleanup frames."""
|
|
12461
|
+
keep_frames = '--keep-frames' in args
|
|
12462
|
+
state = _watch_load_state()
|
|
12463
|
+
if not state:
|
|
12464
|
+
print(json.dumps({'ok': True, 'message': 'no active watch session'}))
|
|
12465
|
+
return
|
|
12466
|
+
|
|
12467
|
+
pid = state.get('pid')
|
|
12468
|
+
killed = False
|
|
12469
|
+
if pid and _watch_pid_alive(pid):
|
|
12470
|
+
try:
|
|
12471
|
+
os.kill(int(pid), 15)
|
|
12472
|
+
killed = True
|
|
12473
|
+
except OSError:
|
|
12474
|
+
pass
|
|
12475
|
+
for _ in range(40):
|
|
12476
|
+
if not _watch_pid_alive(pid):
|
|
12477
|
+
break
|
|
12478
|
+
time.sleep(0.05)
|
|
12479
|
+
if _watch_pid_alive(pid):
|
|
12480
|
+
try:
|
|
12481
|
+
os.kill(int(pid), 9)
|
|
12482
|
+
except OSError:
|
|
12483
|
+
pass
|
|
12484
|
+
|
|
12485
|
+
removed = 0
|
|
12486
|
+
if not keep_frames:
|
|
12487
|
+
fdir = _watch_frames_dir()
|
|
12488
|
+
if os.path.isdir(fdir):
|
|
12489
|
+
for name in os.listdir(fdir):
|
|
12490
|
+
if name.endswith('.jpg'):
|
|
12491
|
+
try:
|
|
12492
|
+
os.remove(os.path.join(fdir, name))
|
|
12493
|
+
removed += 1
|
|
12494
|
+
except OSError:
|
|
12495
|
+
pass
|
|
12496
|
+
try:
|
|
12497
|
+
os.remove(_watch_index_path())
|
|
12498
|
+
except OSError:
|
|
12499
|
+
pass
|
|
12500
|
+
|
|
12501
|
+
_watch_clear_state()
|
|
12502
|
+
print(json.dumps({
|
|
12503
|
+
'ok': True,
|
|
12504
|
+
'killed': killed,
|
|
12505
|
+
'pid': pid,
|
|
12506
|
+
'frames_removed': removed,
|
|
12507
|
+
'kept_frames': keep_frames,
|
|
12508
|
+
}, indent=2))
|
|
12509
|
+
|
|
12510
|
+
|
|
12511
|
+
def cmd_watch_status(*args):
|
|
12512
|
+
"""Report daemon state, frame count, disk usage."""
|
|
12513
|
+
state = _watch_load_state()
|
|
12514
|
+
frames = _watch_list_frames()
|
|
12515
|
+
total_size = sum(os.path.getsize(p) for _, p in frames if os.path.exists(p)) if frames else 0
|
|
12516
|
+
|
|
12517
|
+
out = {
|
|
12518
|
+
'running': bool(state and _watch_pid_alive(state.get('pid'))),
|
|
12519
|
+
'frames': len(frames),
|
|
12520
|
+
'disk_bytes': total_size,
|
|
12521
|
+
'disk_mb': round(total_size / (1024 * 1024), 2),
|
|
12522
|
+
'oldest_ts_ms': frames[0][0] if frames else None,
|
|
12523
|
+
'newest_ts_ms': frames[-1][0] if frames else None,
|
|
12524
|
+
}
|
|
12525
|
+
if state:
|
|
12526
|
+
out['pid'] = state.get('pid')
|
|
12527
|
+
out['url'] = state.get('url') or '(attach)'
|
|
12528
|
+
out['fps'] = state.get('fps')
|
|
12529
|
+
out['started_at_ms'] = state.get('started_at_ms')
|
|
12530
|
+
out['screencast_started_at_ms'] = state.get('screencast_started_at_ms')
|
|
12531
|
+
out['retention_s'] = state.get('retention_s')
|
|
12532
|
+
out['disk_cap_mb'] = (state.get('disk_cap_bytes') or 0) // (1024 * 1024)
|
|
12533
|
+
print(json.dumps(out, indent=2))
|
|
12534
|
+
|
|
12535
|
+
|
|
12536
|
+
def _watch_last_query_path():
|
|
12537
|
+
return os.path.join(_watch_dir(), 'last-query.json')
|
|
12538
|
+
|
|
12539
|
+
|
|
12540
|
+
def cmd_watch_query(*args):
|
|
12541
|
+
"""Return frame paths inside a time window as JSON.
|
|
12542
|
+
|
|
12543
|
+
cdpilot watch query --at <mm:ss|sec> --window <Ns> [--max=16]
|
|
12544
|
+
cdpilot watch query --since-last [--max=16]
|
|
12545
|
+
cdpilot watch query --last <Ns> [--max=16] (relative to newest frame)
|
|
12546
|
+
|
|
12547
|
+
Time mapping: --at is interpreted as VIDEO time, measured from the moment
|
|
12548
|
+
the screencast started (state['screencast_started_at_ms']). This matches
|
|
12549
|
+
user intuition for "what happened at 0:35" because we begin capturing
|
|
12550
|
+
right after autoplay.
|
|
12551
|
+
"""
|
|
12552
|
+
state = _watch_load_state()
|
|
12553
|
+
if not state:
|
|
12554
|
+
print(json.dumps({'frames': [], 'count': 0, 'error': 'no watch session'}))
|
|
12555
|
+
return
|
|
12556
|
+
|
|
12557
|
+
max_frames = 16
|
|
12558
|
+
at_s = None
|
|
12559
|
+
window_s = 5.0
|
|
12560
|
+
since_last = False
|
|
12561
|
+
last_window = None
|
|
12562
|
+
|
|
12563
|
+
args_list = list(args)
|
|
12564
|
+
i = 0
|
|
12565
|
+
while i < len(args_list):
|
|
12566
|
+
a = args_list[i]
|
|
12567
|
+
if a == '--at' and i + 1 < len(args_list):
|
|
12568
|
+
at_s = _watch_parse_timecode(args_list[i + 1])
|
|
12569
|
+
i += 2
|
|
12570
|
+
elif a.startswith('--at='):
|
|
12571
|
+
at_s = _watch_parse_timecode(a.split('=', 1)[1])
|
|
12572
|
+
i += 1
|
|
12573
|
+
elif a == '--window' and i + 1 < len(args_list):
|
|
12574
|
+
window_s = _watch_parse_window(args_list[i + 1])
|
|
12575
|
+
i += 2
|
|
12576
|
+
elif a.startswith('--window='):
|
|
12577
|
+
window_s = _watch_parse_window(a.split('=', 1)[1])
|
|
12578
|
+
i += 1
|
|
12579
|
+
elif a == '--max' and i + 1 < len(args_list):
|
|
12580
|
+
try:
|
|
12581
|
+
max_frames = max(1, min(64, int(args_list[i + 1])))
|
|
12582
|
+
except ValueError:
|
|
12583
|
+
pass
|
|
12584
|
+
i += 2
|
|
12585
|
+
elif a.startswith('--max='):
|
|
12586
|
+
try:
|
|
12587
|
+
max_frames = max(1, min(64, int(a.split('=', 1)[1])))
|
|
12588
|
+
except ValueError:
|
|
12589
|
+
pass
|
|
12590
|
+
i += 1
|
|
12591
|
+
elif a == '--since-last':
|
|
12592
|
+
since_last = True
|
|
12593
|
+
i += 1
|
|
12594
|
+
elif a == '--last' and i + 1 < len(args_list):
|
|
12595
|
+
last_window = _watch_parse_window(args_list[i + 1])
|
|
12596
|
+
i += 2
|
|
12597
|
+
elif a.startswith('--last='):
|
|
12598
|
+
last_window = _watch_parse_window(a.split('=', 1)[1])
|
|
12599
|
+
i += 1
|
|
12600
|
+
else:
|
|
12601
|
+
i += 1
|
|
12602
|
+
|
|
12603
|
+
frames = _watch_list_frames()
|
|
12604
|
+
if not frames:
|
|
12605
|
+
print(json.dumps({'frames': [], 'count': 0, 'duration_s': 0}))
|
|
12606
|
+
return
|
|
12607
|
+
|
|
12608
|
+
sc_start_ms = state.get('screencast_started_at_ms') or state.get('started_at_ms') or frames[0][0]
|
|
12609
|
+
selected = []
|
|
12610
|
+
|
|
12611
|
+
if since_last:
|
|
12612
|
+
last_q = 0
|
|
12613
|
+
try:
|
|
12614
|
+
with open(_watch_last_query_path()) as f:
|
|
12615
|
+
last_q = int(json.load(f).get('ts_ms') or 0)
|
|
12616
|
+
except (OSError, ValueError):
|
|
12617
|
+
last_q = 0
|
|
12618
|
+
selected = [(ts, p) for ts, p in frames if ts > last_q]
|
|
12619
|
+
elif last_window is not None:
|
|
12620
|
+
newest_ts = frames[-1][0]
|
|
12621
|
+
lo_ms = newest_ts - int(last_window * 1000)
|
|
12622
|
+
selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
|
|
12623
|
+
elif at_s is not None:
|
|
12624
|
+
# Map video time to wall-clock ms
|
|
12625
|
+
center_ms = sc_start_ms + int(at_s * 1000)
|
|
12626
|
+
half_ms = int((window_s / 2.0) * 1000)
|
|
12627
|
+
lo_ms = center_ms - half_ms
|
|
12628
|
+
hi_ms = center_ms + half_ms
|
|
12629
|
+
selected = [(ts, p) for ts, p in frames if lo_ms <= ts <= hi_ms]
|
|
12630
|
+
else:
|
|
12631
|
+
# Default: the latest <window_s> seconds
|
|
12632
|
+
newest_ts = frames[-1][0]
|
|
12633
|
+
lo_ms = newest_ts - int(window_s * 1000)
|
|
12634
|
+
selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
|
|
12635
|
+
|
|
12636
|
+
# Downsample to max_frames (uniform pick, keep first + last)
|
|
12637
|
+
if len(selected) > max_frames:
|
|
12638
|
+
# Try motion-aware picking when PIL is available
|
|
12639
|
+
motion_scores = []
|
|
12640
|
+
for j in range(1, len(selected)):
|
|
12641
|
+
d = _watch_frame_diff(selected[j - 1][1], selected[j][1])
|
|
12642
|
+
motion_scores.append(d if d is not None else 0.0)
|
|
12643
|
+
if motion_scores and any(s is not None for s in motion_scores):
|
|
12644
|
+
# Pick frames around the highest-motion peaks
|
|
12645
|
+
indices = sorted(range(len(motion_scores)),
|
|
12646
|
+
key=lambda k: motion_scores[k], reverse=True)
|
|
12647
|
+
pick_idx = sorted(set([0, len(selected) - 1] + indices[:max_frames - 2]))
|
|
12648
|
+
pick_idx = pick_idx[:max_frames]
|
|
12649
|
+
selected = [selected[k] for k in pick_idx]
|
|
12650
|
+
else:
|
|
12651
|
+
# Uniform downsample
|
|
12652
|
+
step = len(selected) / max_frames
|
|
12653
|
+
picks = [int(j * step) for j in range(max_frames)]
|
|
12654
|
+
picks[-1] = len(selected) - 1
|
|
12655
|
+
selected = [selected[k] for k in picks]
|
|
12656
|
+
|
|
12657
|
+
if selected:
|
|
12658
|
+
try:
|
|
12659
|
+
with open(_watch_last_query_path(), 'w') as f:
|
|
12660
|
+
json.dump({'ts_ms': selected[-1][0]}, f)
|
|
12661
|
+
except OSError:
|
|
12662
|
+
pass
|
|
12663
|
+
|
|
12664
|
+
duration_s = (selected[-1][0] - selected[0][0]) / 1000.0 if len(selected) >= 2 else 0.0
|
|
12665
|
+
out = {
|
|
12666
|
+
'frames': [p for _, p in selected],
|
|
12667
|
+
'timestamps_ms': [t for t, _ in selected],
|
|
12668
|
+
'count': len(selected),
|
|
12669
|
+
'duration_s': round(duration_s, 3),
|
|
12670
|
+
'window_s': window_s,
|
|
12671
|
+
'at_s': at_s,
|
|
12672
|
+
'screencast_started_at_ms': sc_start_ms,
|
|
12673
|
+
}
|
|
12674
|
+
print(json.dumps(out, indent=2))
|
|
12675
|
+
|
|
12676
|
+
|
|
12677
|
+
def cmd_watch_ask(*args):
|
|
12678
|
+
"""Tiny natural-language wrapper around `watch query`.
|
|
12679
|
+
|
|
12680
|
+
cdpilot watch ask "0:35'te kedi sola mı sağa mı koştu?"
|
|
12681
|
+
cdpilot watch ask "son 5 saniyede ne oldu"
|
|
12682
|
+
|
|
12683
|
+
We don't actually do LLM inference here — this command just parses a
|
|
12684
|
+
time window out of the question and emits the same JSON shape as
|
|
12685
|
+
`watch query` so the orchestrator (Claude) can pick up the frames and
|
|
12686
|
+
feed them to a multimodal API itself.
|
|
12687
|
+
"""
|
|
12688
|
+
if not args:
|
|
12689
|
+
print(json.dumps({'error': 'question required'}))
|
|
12690
|
+
sys.exit(1)
|
|
12691
|
+
question = ' '.join(args)
|
|
12692
|
+
q_lower = question.lower()
|
|
12693
|
+
|
|
12694
|
+
# Find mm:ss or m:ss anywhere in the string
|
|
12695
|
+
m = _re.search(r'(\d+):(\d{1,2})(?:\.\d+)?', question)
|
|
12696
|
+
at_s = None
|
|
12697
|
+
if m:
|
|
12698
|
+
at_s = float(m.group(1)) * 60 + float(m.group(2))
|
|
12699
|
+
|
|
12700
|
+
# "son N saniye(de)" / "last N seconds"
|
|
12701
|
+
last_match = _re.search(r'(?:son|last)\s+(\d+(?:\.\d+)?)\s*(?:saniye|sec|seconds?|s\b)', q_lower)
|
|
12702
|
+
last_window = float(last_match.group(1)) if last_match else None
|
|
12703
|
+
|
|
12704
|
+
forward = []
|
|
12705
|
+
if at_s is not None:
|
|
12706
|
+
forward = ['--at', f'{int(at_s // 60)}:{at_s % 60:.2f}'.rstrip('0').rstrip('.'),
|
|
12707
|
+
'--window', '5s']
|
|
12708
|
+
elif last_window is not None:
|
|
12709
|
+
forward = ['--last', f'{last_window}s']
|
|
12710
|
+
else:
|
|
12711
|
+
forward = ['--last', '5s']
|
|
12712
|
+
|
|
12713
|
+
forward += ['--max', '8']
|
|
12714
|
+
cmd_watch_query(*forward)
|
|
12715
|
+
|
|
12716
|
+
|
|
12717
|
+
def _dispatch_watch_cmd(args):
|
|
12718
|
+
"""Parse `cdpilot watch <sub> ...` and dispatch. Sync wrapper."""
|
|
12719
|
+
sub = args[0] if args else ''
|
|
12720
|
+
rest = args[1:]
|
|
12721
|
+
if not sub or sub in ('--help', '-h', 'help'):
|
|
12722
|
+
print("Usage: cdpilot watch <subcommand> [options]", file=sys.stderr)
|
|
12723
|
+
print("", file=sys.stderr)
|
|
12724
|
+
print("Subcommands:", file=sys.stderr)
|
|
12725
|
+
print(" start [url|-] [--fps=N] [--quality=N] [--max-width=N]", file=sys.stderr)
|
|
12726
|
+
print(" [--retention=SEC] [--disk-cap=MB] [--seek=MM:SS]", file=sys.stderr)
|
|
12727
|
+
print(" stop [--keep-frames]", file=sys.stderr)
|
|
12728
|
+
print(" query --at MM:SS --window 5s [--max=16]", file=sys.stderr)
|
|
12729
|
+
print(" query --since-last [--max=16]", file=sys.stderr)
|
|
12730
|
+
print(" query --last 5s [--max=16]", file=sys.stderr)
|
|
12731
|
+
print(" status", file=sys.stderr)
|
|
12732
|
+
print(" ask \"<natural-language question>\"", file=sys.stderr)
|
|
12733
|
+
sys.exit(0)
|
|
12734
|
+
if sub == 'start':
|
|
12735
|
+
cmd_watch_start(*rest)
|
|
12736
|
+
elif sub == 'stop':
|
|
12737
|
+
cmd_watch_stop(*rest)
|
|
12738
|
+
elif sub == 'query':
|
|
12739
|
+
cmd_watch_query(*rest)
|
|
12740
|
+
elif sub == 'status':
|
|
12741
|
+
cmd_watch_status(*rest)
|
|
12742
|
+
elif sub == 'ask':
|
|
12743
|
+
cmd_watch_ask(*rest)
|
|
12744
|
+
else:
|
|
12745
|
+
print(f"Unknown watch subcommand: {sub}. Run: cdpilot watch --help", file=sys.stderr)
|
|
12746
|
+
sys.exit(1)
|
|
12747
|
+
|
|
12748
|
+
|
|
12749
|
+
# ─── End cdpilot watch namespace ──────────────────────────────────────────────
|
|
12750
|
+
|
|
12751
|
+
|
|
12752
|
+
# ─── CLI ───
|
|
12753
|
+
|
|
12754
|
+
if __name__ == "__main__":
|
|
12755
|
+
if len(sys.argv) < 2:
|
|
12756
|
+
print(__doc__)
|
|
12757
|
+
sys.exit(0)
|
|
12758
|
+
|
|
12759
|
+
cmd = sys.argv[1]
|
|
12760
|
+
args = sys.argv[2:]
|
|
12761
|
+
|
|
12762
|
+
# POSIX-style flag aliases — accepted at the Python entry point too,
|
|
12763
|
+
# not just at the Node wrapper. Lets `python src/cdpilot.py --version`
|
|
12764
|
+
# work in CI and for users who skip the Node wrapper entirely.
|
|
12765
|
+
if cmd in ('--version', '-v'):
|
|
12766
|
+
cmd = 'version'
|
|
10427
12767
|
elif cmd in ('--help', '-h'):
|
|
10428
12768
|
print(__doc__)
|
|
10429
12769
|
sys.exit(0)
|
|
10430
12770
|
|
|
12771
|
+
# Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
|
|
12772
|
+
# forks this process with the flag so the screencast consumer runs in
|
|
12773
|
+
# the background while the foreground process returns immediately.
|
|
12774
|
+
if cmd == WATCH_DAEMON_FLAG:
|
|
12775
|
+
_cmd_watch_daemon_entry()
|
|
12776
|
+
sys.exit(0)
|
|
12777
|
+
|
|
10431
12778
|
sync_cmds = {
|
|
10432
12779
|
'launch': cmd_launch,
|
|
10433
12780
|
'tabs': cmd_tabs,
|
|
@@ -10437,6 +12784,7 @@ if __name__ == "__main__":
|
|
|
10437
12784
|
'proxy': lambda: cmd_proxy(*args),
|
|
10438
12785
|
'headless': lambda: cmd_headless(args[0] if args else None),
|
|
10439
12786
|
'stealth': lambda: cmd_stealth(args[0] if args else None),
|
|
12787
|
+
'mode': lambda: cmd_mode(args[0] if args else None),
|
|
10440
12788
|
'block': lambda: cmd_block(*args),
|
|
10441
12789
|
'context': lambda: cmd_context(*args),
|
|
10442
12790
|
'show': lambda: cmd_show(args[0] if args else None),
|
|
@@ -10458,6 +12806,7 @@ if __name__ == "__main__":
|
|
|
10458
12806
|
'test': lambda: cmd_test_dispatch(args),
|
|
10459
12807
|
'trace': lambda: cmd_trace_dispatch(args),
|
|
10460
12808
|
'blog': lambda: _dispatch_blog_cmd(args),
|
|
12809
|
+
'watch': lambda: _dispatch_watch_cmd(args),
|
|
10461
12810
|
}
|
|
10462
12811
|
|
|
10463
12812
|
if cmd == "serve":
|
|
@@ -10497,6 +12846,15 @@ if __name__ == "__main__":
|
|
|
10497
12846
|
cmd_switch_tab(args[0])
|
|
10498
12847
|
sys.exit(0)
|
|
10499
12848
|
|
|
12849
|
+
# `stop --smart` aliases the smart close (owned tabs + browser-if-empty).
|
|
12850
|
+
# Bare `stop` keeps its legacy full-kill behavior for backward compatibility.
|
|
12851
|
+
if cmd == "stop" and "--smart" in args:
|
|
12852
|
+
asyncio.run(cmd_close(
|
|
12853
|
+
force_browser="--force" in args,
|
|
12854
|
+
keep_browser=("--keep" in args or "--keep-browser" in args),
|
|
12855
|
+
))
|
|
12856
|
+
sys.exit(0)
|
|
12857
|
+
|
|
10500
12858
|
if cmd in sync_cmds:
|
|
10501
12859
|
sync_cmds[cmd]()
|
|
10502
12860
|
sys.exit(0)
|
|
@@ -10557,7 +12915,10 @@ if __name__ == "__main__":
|
|
|
10557
12915
|
"emulate": lambda: (require_args(1, "emulate <device>"), None)[1] if not args else cmd_emulate(args[0]),
|
|
10558
12916
|
"glow": lambda: cmd_glow(args[0] if args else "on"),
|
|
10559
12917
|
"debug": lambda: cmd_debug(args[0] if args else None),
|
|
10560
|
-
"close": cmd_close
|
|
12918
|
+
"close": lambda: cmd_close(
|
|
12919
|
+
force_browser="--force" in args,
|
|
12920
|
+
keep_browser=("--keep" in args or "--keep-browser" in args),
|
|
12921
|
+
),
|
|
10561
12922
|
'new-tab': lambda: cmd_new_tab(args[0] if args else 'about:blank'),
|
|
10562
12923
|
'close-tab': lambda: cmd_close_tab(args[0] if args else None),
|
|
10563
12924
|
'pdf': lambda: cmd_pdf(args[0] if args else None),
|
|
@@ -10614,6 +12975,15 @@ if __name__ == "__main__":
|
|
|
10614
12975
|
'captcha-check': cmd_captcha_check,
|
|
10615
12976
|
'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
|
|
10616
12977
|
'captcha': lambda: cmd_captcha_dispatch(args),
|
|
12978
|
+
'friction': cmd_friction,
|
|
12979
|
+
'captcha-solve': lambda: cmd_captcha_solve(
|
|
12980
|
+
next((a.split('=')[1] for a in args if a.startswith('--provider=')),
|
|
12981
|
+
args[args.index('--provider') + 1] if '--provider' in args and args.index('--provider') + 1 < len(args)
|
|
12982
|
+
else (next((a for a in args if not a.startswith('--')), None))),
|
|
12983
|
+
),
|
|
12984
|
+
'profile': lambda: cmd_profile_dispatch(args),
|
|
12985
|
+
'press-hold': lambda: cmd_press_hold(
|
|
12986
|
+
next((a for a in args if not a.startswith('--')), None)),
|
|
10617
12987
|
'agent': lambda: _dispatch_agent_cmd(args),
|
|
10618
12988
|
}
|
|
10619
12989
|
|
|
@@ -10621,7 +12991,8 @@ if __name__ == "__main__":
|
|
|
10621
12991
|
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
|
|
10622
12992
|
'dialog', 'download', 'throttle', 'permission', 'intercept',
|
|
10623
12993
|
'batch', 'screenshot-diff', 'run',
|
|
10624
|
-
'captcha-check', 'captcha-wait', 'captcha',
|
|
12994
|
+
'captcha-check', 'captcha-wait', 'captcha', 'friction',
|
|
12995
|
+
'profile',
|
|
10625
12996
|
'cookies'} # v0.6.1: cookies auto-config doesn't need browser
|
|
10626
12997
|
# Clean up idle sessions before running any command
|
|
10627
12998
|
_cleanup_idle_sessions()
|