cdpilot 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -9
- package/bin/cdpilot.js +78 -13
- package/package.json +14 -3
- package/src/cdpilot.py +2735 -177
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.1"
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import atexit
|
|
@@ -35,13 +35,16 @@ import secrets
|
|
|
35
35
|
import glob
|
|
36
36
|
import datetime
|
|
37
37
|
import concurrent.futures
|
|
38
|
+
import tempfile
|
|
38
39
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
40
|
|
|
40
41
|
# ─── Project-Based Multi-Instance Configuration ───
|
|
41
42
|
# Each project directory (cwd) gets its own browser instance with
|
|
42
43
|
# a unique CDP port and isolated profile directory. Zero-config.
|
|
43
44
|
|
|
44
|
-
CDPILOT_HOME
|
|
45
|
+
# CDPILOT_HOME override lets tests (and advanced users) point the registry
|
|
46
|
+
# at an isolated directory instead of the real ~/.cdpilot.
|
|
47
|
+
CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
|
|
45
48
|
REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
|
|
46
49
|
CDPILOT_PORT_RANGE_START = 9222
|
|
47
50
|
CDPILOT_PORT_RANGE_END = 9322
|
|
@@ -65,10 +68,19 @@ def _get_project_id():
|
|
|
65
68
|
|
|
66
69
|
|
|
67
70
|
def _is_port_free(port):
|
|
68
|
-
"""Check if a port is available for binding.
|
|
71
|
+
"""Check if a port is available for binding.
|
|
72
|
+
|
|
73
|
+
On Windows, SO_REUSEADDR lets a socket bind on top of a port that another
|
|
74
|
+
socket is actively listening on, so a probe using it called every port
|
|
75
|
+
free (and registry cleanup pruned live entries). Exclusive use fails when
|
|
76
|
+
anything holds the port. POSIX SO_REUSEADDR only skips TIME_WAIT.
|
|
77
|
+
"""
|
|
69
78
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
70
79
|
try:
|
|
71
|
-
|
|
80
|
+
if os.name == "nt":
|
|
81
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
82
|
+
else:
|
|
83
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
72
84
|
s.bind(("127.0.0.1", port))
|
|
73
85
|
return True
|
|
74
86
|
except OSError:
|
|
@@ -86,10 +98,26 @@ def _load_registry():
|
|
|
86
98
|
|
|
87
99
|
|
|
88
100
|
def _save_registry(projects):
|
|
89
|
-
"""Write the global project registry.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
"""Write the global project registry atomically.
|
|
102
|
+
|
|
103
|
+
Writes to a temp file in the same directory and os.replace()s it into
|
|
104
|
+
place, so a concurrent cdpilot invocation reading registry.json always
|
|
105
|
+
sees either the fully-old or fully-new content — never a torn/partial
|
|
106
|
+
write (os.replace is atomic on both POSIX and Windows).
|
|
107
|
+
"""
|
|
108
|
+
reg_dir = os.path.dirname(REGISTRY_FILE)
|
|
109
|
+
os.makedirs(reg_dir, exist_ok=True)
|
|
110
|
+
fd, tmp_path = tempfile.mkstemp(prefix='.registry-', suffix='.tmp', dir=reg_dir)
|
|
111
|
+
try:
|
|
112
|
+
with os.fdopen(fd, 'w') as f:
|
|
113
|
+
json.dump({"version": 1, "projects": projects}, f, indent=2)
|
|
114
|
+
os.replace(tmp_path, REGISTRY_FILE)
|
|
115
|
+
except BaseException:
|
|
116
|
+
try:
|
|
117
|
+
os.remove(tmp_path)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
raise
|
|
93
121
|
|
|
94
122
|
|
|
95
123
|
def _register_project(project_id, port, profile_dir, pid=None):
|
|
@@ -108,25 +136,83 @@ def _register_project(project_id, port, profile_dir, pid=None):
|
|
|
108
136
|
_save_registry(registry)
|
|
109
137
|
|
|
110
138
|
|
|
139
|
+
def _pid_alive(pid):
|
|
140
|
+
"""True if a process with this pid exists. Never signals it.
|
|
141
|
+
|
|
142
|
+
Sending signal 0 is a harmless existence probe on POSIX, but on Windows
|
|
143
|
+
Python maps every signal other than CTRL_C/CTRL_BREAK to TerminateProcess:
|
|
144
|
+
the "probe" kills the process. Registry pids are stale by nature and may
|
|
145
|
+
have been reused by an unrelated program, so on Windows ask the kernel
|
|
146
|
+
through OpenProcess/GetExitCodeProcess instead. Defined this early because
|
|
147
|
+
it runs at import time (_resolve_project_config -> _allocate_port).
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
pid = int(pid)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return False
|
|
153
|
+
if pid <= 0:
|
|
154
|
+
return False
|
|
155
|
+
if os.name == "nt":
|
|
156
|
+
import ctypes
|
|
157
|
+
kernel32 = ctypes.windll.kernel32
|
|
158
|
+
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
|
|
159
|
+
if not handle:
|
|
160
|
+
return False
|
|
161
|
+
try:
|
|
162
|
+
code = ctypes.c_ulong()
|
|
163
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
164
|
+
return False
|
|
165
|
+
return code.value == 259 # STILL_ACTIVE
|
|
166
|
+
finally:
|
|
167
|
+
kernel32.CloseHandle(handle)
|
|
168
|
+
try:
|
|
169
|
+
os.kill(pid, 0)
|
|
170
|
+
except ProcessLookupError:
|
|
171
|
+
return False
|
|
172
|
+
except PermissionError:
|
|
173
|
+
return True # exists, just owned by another user
|
|
174
|
+
except OSError:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_registry_entry_dead(info):
|
|
180
|
+
"""A registry entry is dead only when we can PROVE nothing is using it.
|
|
181
|
+
|
|
182
|
+
Dead = nothing is listening on its port AND (if a pid was recorded)
|
|
183
|
+
that process is no longer alive. If the port is currently bound, we
|
|
184
|
+
never call it dead even if our own bookkeeping (status/pid) looks
|
|
185
|
+
stale — it may be another project's live browser instance, and a
|
|
186
|
+
live entry must never be pruned.
|
|
187
|
+
"""
|
|
188
|
+
port = info.get("port")
|
|
189
|
+
if port and not _is_port_free(port):
|
|
190
|
+
return False
|
|
191
|
+
pid = info.get("pid")
|
|
192
|
+
if pid and _pid_alive(pid):
|
|
193
|
+
return False
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
111
197
|
def _cleanup_registry():
|
|
112
|
-
"""
|
|
198
|
+
"""Prune dead entries from the registry and return what's left.
|
|
199
|
+
|
|
200
|
+
Called before every port allocation (see _allocate_port) so the
|
|
201
|
+
registry never accumulates stale entries that starve the
|
|
202
|
+
9222-9322 port range — the root cause of the "No free port" failure
|
|
203
|
+
documented in .claude/docs/vaat-denetimi-2026-09-27.md.
|
|
204
|
+
"""
|
|
113
205
|
registry = _load_registry()
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
info["status"] = "stopped"
|
|
120
|
-
info["pid"] = None
|
|
121
|
-
changed = True
|
|
122
|
-
if changed:
|
|
123
|
-
_save_registry(registry)
|
|
124
|
-
return registry
|
|
206
|
+
alive = {pid_key: info for pid_key, info in registry.items()
|
|
207
|
+
if not _is_registry_entry_dead(info)}
|
|
208
|
+
if len(alive) != len(registry):
|
|
209
|
+
_save_registry(alive)
|
|
210
|
+
return alive
|
|
125
211
|
|
|
126
212
|
|
|
127
213
|
def _allocate_port(project_id):
|
|
128
214
|
"""Find a free port for the given project."""
|
|
129
|
-
registry =
|
|
215
|
+
registry = _cleanup_registry()
|
|
130
216
|
|
|
131
217
|
# Reuse existing port if still free
|
|
132
218
|
if project_id in registry:
|
|
@@ -236,7 +322,80 @@ window.__cdpilot_waitFor = function(selector, timeout) {
|
|
|
236
322
|
# - Idempotent: guarded by window.__cdpilot_stealth flag.
|
|
237
323
|
# - Fails silently per-patch; one failure cannot break the page.
|
|
238
324
|
# - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
|
|
239
|
-
|
|
325
|
+
#
|
|
326
|
+
# THREE-TIER MODEL (crawl4ai-style regular -> stealth -> undetected):
|
|
327
|
+
# - regular : NO fingerprint patch injected at all. Cleanest, fastest,
|
|
328
|
+
# relies only on --disable-blink-features=AutomationControlled
|
|
329
|
+
# set at launch. Lowest entropy -> fewest leaks. Default tier.
|
|
330
|
+
# - stealth : STEALTH_JS_LIGHT - webdriver + chrome.runtime + permissions
|
|
331
|
+
# reconcile ONLY. Deliberately OMITS the plugin-array spoof,
|
|
332
|
+
# which Stealth Bench V1 proved is itself a leak source
|
|
333
|
+
# (bot.sannysoft.com surfaced garbage plugin filenames like
|
|
334
|
+
# "rCJMteXy" from the synthetic PluginArray). Light tier is a
|
|
335
|
+
# strict subset of FULL.
|
|
336
|
+
# - undetected : STEALTH_JS_FULL - the full body (light subset + plugins +
|
|
337
|
+
# Worker patch + WebGL vendor + hardwareConcurrency). Highest
|
|
338
|
+
# plausibility on naive checks but highest entropy; pair with
|
|
339
|
+
# behavioral entropy/humanize for the hardest targets.
|
|
340
|
+
#
|
|
341
|
+
# STEALTH_JS retains the FULL body for backward compatibility - every existing
|
|
342
|
+
# reference (`cdpilot stealth on`, adaptive escalation) maps to undetected.
|
|
343
|
+
|
|
344
|
+
# Light tier: the SAFE subset. webdriver + chrome.runtime + permissions only.
|
|
345
|
+
# No plugin spoofing (bench leak), no WebGL vendor override, no Worker wrap.
|
|
346
|
+
STEALTH_JS_LIGHT = r"""
|
|
347
|
+
(function() {
|
|
348
|
+
'use strict';
|
|
349
|
+
if (window.__cdpilot_stealth) return;
|
|
350
|
+
try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
|
|
351
|
+
|
|
352
|
+
// 1) navigator.webdriver - only patch if it's actually `true`.
|
|
353
|
+
try {
|
|
354
|
+
var wdValue;
|
|
355
|
+
try { wdValue = navigator.webdriver; } catch (_) {}
|
|
356
|
+
if (wdValue === true) {
|
|
357
|
+
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
|
358
|
+
get: function() { return false; },
|
|
359
|
+
configurable: true
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
} catch (e) {}
|
|
363
|
+
|
|
364
|
+
// 2) window.chrome - real Chrome exposes chrome.runtime even without extensions
|
|
365
|
+
try {
|
|
366
|
+
if (!window.chrome) {
|
|
367
|
+
window.chrome = {};
|
|
368
|
+
}
|
|
369
|
+
if (!window.chrome.runtime) {
|
|
370
|
+
window.chrome.runtime = {
|
|
371
|
+
OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
|
|
372
|
+
PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (typeof window.chrome.app === 'undefined') {
|
|
376
|
+
window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
|
|
377
|
+
}
|
|
378
|
+
} catch (e) {}
|
|
379
|
+
|
|
380
|
+
// 3) Permissions.query - reconcile with Notification.permission (classic tell)
|
|
381
|
+
try {
|
|
382
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
383
|
+
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
|
384
|
+
navigator.permissions.query = function(p) {
|
|
385
|
+
if (p && p.name === 'notifications') {
|
|
386
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
387
|
+
}
|
|
388
|
+
return origQuery(p);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {}
|
|
392
|
+
})();
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
# Full tier (a.k.a. "undetected"): the complete body. Light subset + plugin
|
|
396
|
+
# array + Worker patch + WebGL vendor + hardwareConcurrency. STEALTH_JS is kept
|
|
397
|
+
# as an alias of FULL so every legacy reference still resolves.
|
|
398
|
+
STEALTH_JS_FULL = r"""
|
|
240
399
|
(function() {
|
|
241
400
|
'use strict';
|
|
242
401
|
if (window.__cdpilot_stealth) return;
|
|
@@ -404,6 +563,11 @@ STEALTH_JS = r"""
|
|
|
404
563
|
})();
|
|
405
564
|
"""
|
|
406
565
|
|
|
566
|
+
# Backward-compat alias: STEALTH_JS == the FULL (undetected) body. Every legacy
|
|
567
|
+
# reference (`cdpilot stealth on`, adaptive escalation, navigate inject) keeps
|
|
568
|
+
# resolving to the full patch set, exactly as before the three-tier split.
|
|
569
|
+
STEALTH_JS = STEALTH_JS_FULL
|
|
570
|
+
|
|
407
571
|
# ─── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
408
572
|
# Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
|
|
409
573
|
# Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
|
|
@@ -461,6 +625,14 @@ CAPTCHA_DETECT_JS = r"""
|
|
|
461
625
|
add('geetest', 'GeeTest');
|
|
462
626
|
}
|
|
463
627
|
|
|
628
|
+
// Amazon classic image CAPTCHA ("Type the characters you see in this image")
|
|
629
|
+
// Solvable locally with the optional amazoncaptcha library (image OCR).
|
|
630
|
+
var amzInput = document.querySelector('#captchacharacters');
|
|
631
|
+
var amzImg = document.querySelector('img[src*="opfcaptcha"], img[src*="captcha"][src*="images-na.ssl-images-amazon.com"], form[action*="/errors/validateCaptcha"] img');
|
|
632
|
+
if (amzInput && amzImg) {
|
|
633
|
+
add('amazon-classic', 'Amazon', { img_src: amzImg.src, input_id: amzInput.id || 'captchacharacters' });
|
|
634
|
+
}
|
|
635
|
+
|
|
464
636
|
return JSON.stringify(result);
|
|
465
637
|
} catch (e) {
|
|
466
638
|
return JSON.stringify({ detected: false, error: String(e) });
|
|
@@ -468,9 +640,127 @@ CAPTCHA_DETECT_JS = r"""
|
|
|
468
640
|
})()
|
|
469
641
|
"""
|
|
470
642
|
|
|
643
|
+
# ─── Progressive-Resilience Escalation Ladder ───
|
|
644
|
+
# Real sites stack defenses incrementally (sahibinden.com pattern: random
|
|
645
|
+
# captcha -> rate-limit -> login-wall -> SMS/OTP). CAPTCHA_DETECT_JS only sees
|
|
646
|
+
# the captcha rung. FRICTION_DETECT_JS detects the OTHER friction rungs purely
|
|
647
|
+
# from the DOM (read-only) and reports the HIGHEST one present. Bilingual
|
|
648
|
+
# (Turkish + English) keyword heuristics — TR sites are first-class.
|
|
649
|
+
#
|
|
650
|
+
# Ladder (low -> high):
|
|
651
|
+
# none normal page
|
|
652
|
+
# rate_limited HTTP 429 markers, "too many requests" / "çok fazla istek"
|
|
653
|
+
# soft_captcha (handled by CAPTCHA_DETECT_JS, merged in _detect_friction)
|
|
654
|
+
# login_wall login form gating the real content (sign in / giriş yap)
|
|
655
|
+
# otp_sms verification-code / OTP / SMS input
|
|
656
|
+
# hard_block access denied / banned / 403 forbidden, no content
|
|
657
|
+
FRICTION_DETECT_JS = r"""
|
|
658
|
+
(function() {
|
|
659
|
+
try {
|
|
660
|
+
var out = { level: 'none', signals: [], detail: '' };
|
|
661
|
+
var bodyText = (document.body ? (document.body.innerText || '') : '').toLowerCase();
|
|
662
|
+
var titleText = (document.title || '').toLowerCase();
|
|
663
|
+
var hay = titleText + ' \n ' + bodyText;
|
|
664
|
+
var sample = hay.slice(0, 6000);
|
|
665
|
+
var hasAny = function(words) {
|
|
666
|
+
for (var i = 0; i < words.length; i++) {
|
|
667
|
+
if (sample.indexOf(words[i]) >= 0) return words[i];
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// ── hard_block (highest) ──
|
|
673
|
+
var blockWords = [
|
|
674
|
+
'access denied', 'access to this page has been denied', 'you have been blocked',
|
|
675
|
+
'permanently banned', 'ip has been banned', '403 forbidden', 'forbidden',
|
|
676
|
+
'erişim engellendi', 'erişiminiz engellendi', 'engellendiniz', 'yasaklandı',
|
|
677
|
+
'reddedildi', 'banlandı'
|
|
678
|
+
];
|
|
679
|
+
var blockHit = hasAny(blockWords);
|
|
680
|
+
// Only treat as a hard block when the page has almost no real content
|
|
681
|
+
// (block pages are typically tiny). Avoids false positives on articles
|
|
682
|
+
// that merely mention these words.
|
|
683
|
+
var thinPage = bodyText.replace(/\s+/g, ' ').trim().length < 600;
|
|
684
|
+
|
|
685
|
+
// ── otp_sms ──
|
|
686
|
+
var otpWords = [
|
|
687
|
+
'verification code', 'one-time code', 'one time password', 'otp',
|
|
688
|
+
'sms code', 'code sent to your phone', 'enter the code',
|
|
689
|
+
'doğrulama kodu', 'onay kodu', 'tek kullanımlık', 'telefonunuza gönderilen',
|
|
690
|
+
'sms ile gönderilen', 'sms doğrulama', 'kodu girin'
|
|
691
|
+
];
|
|
692
|
+
var otpInput = document.querySelector(
|
|
693
|
+
'input[autocomplete="one-time-code"], input[name*="otp" i], input[id*="otp" i], ' +
|
|
694
|
+
'input[name*="code" i][maxlength], input[name*="sms" i], input[name*="verification" i], ' +
|
|
695
|
+
'input[id*="verification" i], input[name*="dogrulama" i], input[id*="dogrulama" i]'
|
|
696
|
+
);
|
|
697
|
+
var otpWordHit = hasAny(otpWords);
|
|
698
|
+
|
|
699
|
+
// ── login_wall ──
|
|
700
|
+
var pwInput = document.querySelector('input[type="password"]');
|
|
701
|
+
var loginForm = document.querySelector(
|
|
702
|
+
'form[action*="login" i], form[action*="signin" i], form[action*="giris" i], form[id*="login" i]'
|
|
703
|
+
);
|
|
704
|
+
var loginWords = [
|
|
705
|
+
'sign in', 'log in', 'login to continue', 'please sign in', 'you must be logged in',
|
|
706
|
+
'create an account to continue', 'sign in to continue',
|
|
707
|
+
'giriş yap', 'oturum aç', 'giriş yapın', 'üye girişi', 'devam etmek için giriş',
|
|
708
|
+
'giriş yapmalısınız', 'lütfen giriş yapın', 'hesabınıza giriş'
|
|
709
|
+
];
|
|
710
|
+
var loginWordHit = hasAny(loginWords);
|
|
711
|
+
|
|
712
|
+
// ── rate_limited ──
|
|
713
|
+
var rateWords = [
|
|
714
|
+
'too many requests', 'rate limit', 'rate limited', 'slow down',
|
|
715
|
+
'you are being rate limited', 'request limit', 'try again later',
|
|
716
|
+
'çok fazla istek', 'çok sık istek', 'lütfen yavaşlayın', 'yavaşlayın',
|
|
717
|
+
'istek sınırı', 'çok hızlı', 'daha sonra tekrar deneyin', 'güvenlik doğrulaması'
|
|
718
|
+
];
|
|
719
|
+
var rateWordHit = hasAny(rateWords);
|
|
720
|
+
// 429 surfaced into DOM (some sites print the status), or retry-after hint
|
|
721
|
+
var rate429 = /\b429\b/.test(sample) && (sample.indexOf('request') >= 0 || sample.indexOf('istek') >= 0 || rateWordHit);
|
|
722
|
+
var retryAfter = /retry[\s-]?after/.test(sample);
|
|
723
|
+
|
|
724
|
+
// Decide highest rung present.
|
|
725
|
+
if (blockHit && thinPage) {
|
|
726
|
+
out.level = 'hard_block';
|
|
727
|
+
out.signals.push('keyword:' + blockHit);
|
|
728
|
+
out.detail = 'Access denied / banned page with no real content.';
|
|
729
|
+
} else if (otpWordHit || otpInput) {
|
|
730
|
+
out.level = 'otp_sms';
|
|
731
|
+
if (otpWordHit) out.signals.push('keyword:' + otpWordHit);
|
|
732
|
+
if (otpInput) out.signals.push('input:otp');
|
|
733
|
+
out.detail = 'SMS/OTP verification prompt detected.';
|
|
734
|
+
} else if ((pwInput || loginForm) && loginWordHit) {
|
|
735
|
+
out.level = 'login_wall';
|
|
736
|
+
out.signals.push('keyword:' + loginWordHit);
|
|
737
|
+
if (pwInput) out.signals.push('input:password');
|
|
738
|
+
if (loginForm) out.signals.push('form:login');
|
|
739
|
+
out.detail = 'Login wall gating the page content.';
|
|
740
|
+
} else if (loginWordHit && loginForm) {
|
|
741
|
+
out.level = 'login_wall';
|
|
742
|
+
out.signals.push('keyword:' + loginWordHit);
|
|
743
|
+
out.signals.push('form:login');
|
|
744
|
+
out.detail = 'Login wall gating the page content.';
|
|
745
|
+
} else if (rateWordHit || rate429 || retryAfter) {
|
|
746
|
+
out.level = 'rate_limited';
|
|
747
|
+
if (rateWordHit) out.signals.push('keyword:' + rateWordHit);
|
|
748
|
+
if (rate429) out.signals.push('status:429');
|
|
749
|
+
if (retryAfter) out.signals.push('hint:retry-after');
|
|
750
|
+
out.detail = 'Rate-limit signal detected on page.';
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return JSON.stringify(out);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
return JSON.stringify({ level: 'none', signals: [], detail: '', error: String(e) });
|
|
756
|
+
}
|
|
757
|
+
})()
|
|
758
|
+
"""
|
|
759
|
+
|
|
471
760
|
PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
|
|
472
761
|
HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
|
|
473
762
|
STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
|
|
763
|
+
MODE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'mode.json')
|
|
474
764
|
BLOCK_CONFIG_FILE = os.path.join(PROFILE_DIR, 'block.json')
|
|
475
765
|
CAPTCHA_PROVIDERS_FILE = os.path.join(CDPILOT_HOME, 'captcha-providers.json')
|
|
476
766
|
CAPTCHA_AUTO_FILE = os.path.join(PROFILE_DIR, 'captcha-auto.json')
|
|
@@ -480,6 +770,10 @@ ADAPTIVE_CONFIG_FILE = os.path.join(PROFILE_DIR, 'adaptive.json')
|
|
|
480
770
|
ENTROPY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'entropy.json')
|
|
481
771
|
DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
|
|
482
772
|
SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
|
|
773
|
+
# Tabs that cdpilot itself opened (via go / new-tab / smart-click). The smart
|
|
774
|
+
# `close` command only touches these — user tabs opened manually in the same
|
|
775
|
+
# isolated profile are left alone.
|
|
776
|
+
OWNED_TABS_FILE = os.path.join(PROFILE_DIR, 'owned-tabs.json')
|
|
483
777
|
COOKIES_DIR = os.path.join(CDPILOT_HOME, 'cookies')
|
|
484
778
|
COOKIES_AUTO_CONFIG_FILE = os.path.join(PROFILE_DIR, 'cookies-auto.json')
|
|
485
779
|
CF_CLEARANCE_COOKIES = frozenset({'cf_clearance', '__cf_bm', '_cfuvid'})
|
|
@@ -1030,6 +1324,9 @@ def _create_session_window():
|
|
|
1030
1324
|
"last_used": time.time(),
|
|
1031
1325
|
}
|
|
1032
1326
|
_save_sessions(sessions)
|
|
1327
|
+
# Freshly created by cdpilot -> owned. (The reuse branch above does not
|
|
1328
|
+
# mark, since that tab may have been opened by the user.)
|
|
1329
|
+
_mark_owned_tab(target_id)
|
|
1033
1330
|
|
|
1034
1331
|
return target_id
|
|
1035
1332
|
|
|
@@ -1405,11 +1702,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
1405
1702
|
# as long as this connection lives — which is exactly the window we
|
|
1406
1703
|
# need (until Page.loadEventFired below). Stealth must run before
|
|
1407
1704
|
# any page script inspects navigator; addScript guarantees that.
|
|
1408
|
-
|
|
1409
|
-
|
|
1705
|
+
#
|
|
1706
|
+
# Three-tier selection: regular -> inject nothing, stealth -> LIGHT,
|
|
1707
|
+
# undetected -> FULL. get_mode_config() folds in the legacy stealth
|
|
1708
|
+
# toggle and the CDPILOT_STEALTH env var (set during adaptive
|
|
1709
|
+
# escalation) so existing behavior is preserved.
|
|
1710
|
+
stealth_source = stealth_js_for_tier(get_mode_config())
|
|
1711
|
+
if stealth_source:
|
|
1410
1712
|
await ws.send(json.dumps({
|
|
1411
1713
|
"id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
|
|
1412
|
-
"params": {"source":
|
|
1714
|
+
"params": {"source": stealth_source}
|
|
1413
1715
|
}))
|
|
1414
1716
|
|
|
1415
1717
|
# Apply request blocking BEFORE navigate, on this same WS session.
|
|
@@ -1959,6 +2261,105 @@ def get_headless_config():
|
|
|
1959
2261
|
return False
|
|
1960
2262
|
|
|
1961
2263
|
|
|
2264
|
+
# ─── Three-tier stealth mode (crawl4ai-style escalation) ──────────────────────
|
|
2265
|
+
# Tiers, weakest fingerprint footprint -> strongest spoof:
|
|
2266
|
+
# regular -> inject NOTHING (cleanest, default)
|
|
2267
|
+
# stealth -> STEALTH_JS_LIGHT (webdriver/chrome.runtime/permissions only)
|
|
2268
|
+
# undetected -> STEALTH_JS_FULL (light + plugins + WebGL + Worker)
|
|
2269
|
+
MODE_TIERS = ('regular', 'stealth', 'undetected')
|
|
2270
|
+
DEFAULT_MODE_TIER = 'regular'
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def get_mode_config():
|
|
2274
|
+
"""Return the active stealth tier as a string in MODE_TIERS.
|
|
2275
|
+
|
|
2276
|
+
Resolution order: CDPILOT_MODE env -> mode.json -> legacy stealth on/off
|
|
2277
|
+
-> DEFAULT_MODE_TIER. Legacy `cdpilot stealth on` (no mode.json present)
|
|
2278
|
+
maps to 'undetected' so the old binary toggle still produces the full
|
|
2279
|
+
patch set; `stealth off` (or absent) keeps 'regular'.
|
|
2280
|
+
"""
|
|
2281
|
+
env = os.environ.get('CDPILOT_MODE', '').strip().lower()
|
|
2282
|
+
if env in MODE_TIERS:
|
|
2283
|
+
return env
|
|
2284
|
+
if os.path.exists(MODE_CONFIG_FILE):
|
|
2285
|
+
try:
|
|
2286
|
+
with open(MODE_CONFIG_FILE) as f:
|
|
2287
|
+
tier = json.load(f).get('tier', DEFAULT_MODE_TIER)
|
|
2288
|
+
if tier in MODE_TIERS:
|
|
2289
|
+
return tier
|
|
2290
|
+
except (OSError, ValueError):
|
|
2291
|
+
pass
|
|
2292
|
+
# Backwards-compat: no explicit mode set — honor the legacy stealth toggle.
|
|
2293
|
+
# stealth on -> undetected (full), stealth off -> regular (clean).
|
|
2294
|
+
return 'undetected' if get_stealth_config() else DEFAULT_MODE_TIER
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def set_mode_config(tier):
|
|
2298
|
+
"""Persist the active tier to mode.json. Returns the normalized tier."""
|
|
2299
|
+
tier = (tier or '').strip().lower()
|
|
2300
|
+
if tier not in MODE_TIERS:
|
|
2301
|
+
raise ValueError(f"invalid tier: {tier!r}")
|
|
2302
|
+
_atomic_write_json(MODE_CONFIG_FILE, {'tier': tier})
|
|
2303
|
+
return tier
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def stealth_js_for_tier(tier):
|
|
2307
|
+
"""Return the fingerprint patch JS for a tier, or None for 'regular'.
|
|
2308
|
+
|
|
2309
|
+
regular -> None (inject nothing)
|
|
2310
|
+
stealth -> STEALTH_JS_LIGHT
|
|
2311
|
+
undetected -> STEALTH_JS_FULL
|
|
2312
|
+
"""
|
|
2313
|
+
if tier == 'stealth':
|
|
2314
|
+
return STEALTH_JS_LIGHT
|
|
2315
|
+
if tier == 'undetected':
|
|
2316
|
+
return STEALTH_JS_FULL
|
|
2317
|
+
return None
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
def cmd_mode(tier=None):
|
|
2321
|
+
"""Get or set the three-tier stealth mode (regular | stealth | undetected).
|
|
2322
|
+
|
|
2323
|
+
Usage:
|
|
2324
|
+
cdpilot mode # show current tier + what it injects
|
|
2325
|
+
cdpilot mode regular # no fingerprint patch (cleanest, fastest)
|
|
2326
|
+
cdpilot mode stealth # light patch: webdriver/chrome.runtime/perms
|
|
2327
|
+
cdpilot mode undetected # full patch: + plugins + WebGL + Worker
|
|
2328
|
+
|
|
2329
|
+
Default: regular. Stealth Bench V1 found that the full patch set ALONE
|
|
2330
|
+
lowered scores (-6.3p) because the synthetic plugin array leaks, while
|
|
2331
|
+
light host-learning gained (+6.3p). The 'stealth' tier omits plugin
|
|
2332
|
+
spoofing for this reason. Escalate to 'undetected' only for hard targets.
|
|
2333
|
+
Effect applies on the NEXT navigation. Env override: CDPILOT_MODE=<tier>.
|
|
2334
|
+
"""
|
|
2335
|
+
if tier is None or tier.strip().lower() in ('', 'status'):
|
|
2336
|
+
current = get_mode_config()
|
|
2337
|
+
patch = {
|
|
2338
|
+
'regular': 'none (no fingerprint patch — cleanest)',
|
|
2339
|
+
'stealth': 'STEALTH_JS_LIGHT (webdriver, chrome.runtime, permissions)',
|
|
2340
|
+
'undetected': 'STEALTH_JS_FULL (light + plugins + WebGL + Worker)',
|
|
2341
|
+
}[current]
|
|
2342
|
+
print(f'Mode: {current}')
|
|
2343
|
+
print(f' Injects: {patch}')
|
|
2344
|
+
return
|
|
2345
|
+
t = tier.strip().lower()
|
|
2346
|
+
if t not in MODE_TIERS:
|
|
2347
|
+
print(f"Invalid tier: {tier}. Use one of: {', '.join(MODE_TIERS)}.", file=sys.stderr)
|
|
2348
|
+
sys.exit(1)
|
|
2349
|
+
set_mode_config(t)
|
|
2350
|
+
# Keep the legacy stealth toggle coherent so `stealth status` and any code
|
|
2351
|
+
# still reading get_stealth_config() agree with the tier:
|
|
2352
|
+
# regular -> stealth off
|
|
2353
|
+
# stealth -> stealth on (a patch IS injected, just the light one)
|
|
2354
|
+
# undetected-> stealth on
|
|
2355
|
+
try:
|
|
2356
|
+
_atomic_write_json(STEALTH_CONFIG_FILE, {'stealth': t != 'regular'})
|
|
2357
|
+
except Exception:
|
|
2358
|
+
pass
|
|
2359
|
+
print(f'Mode: {t}')
|
|
2360
|
+
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
2361
|
+
|
|
2362
|
+
|
|
1962
2363
|
def get_stealth_config():
|
|
1963
2364
|
"""Return whether stealth fingerprint patches are enabled.
|
|
1964
2365
|
|
|
@@ -2012,6 +2413,31 @@ def _sanitize_profile_state(profile_dir):
|
|
|
2012
2413
|
|
|
2013
2414
|
# ─── Commands ───
|
|
2014
2415
|
|
|
2416
|
+
def _minimize_browser_window():
|
|
2417
|
+
"""Minimize the browser window via CDP (Browser.setWindowBounds).
|
|
2418
|
+
|
|
2419
|
+
Reliable on macOS where negative --window-position is clamped back on
|
|
2420
|
+
screen. Uses cdpilot's own async cdp_send over the browser-level WS —
|
|
2421
|
+
no new dependency. Best-effort: any failure is swallowed by the caller.
|
|
2422
|
+
"""
|
|
2423
|
+
ver = cdp_get('/json/version') or {}
|
|
2424
|
+
ws_url = ver.get('webSocketDebuggerUrl')
|
|
2425
|
+
if not ws_url:
|
|
2426
|
+
return
|
|
2427
|
+
|
|
2428
|
+
async def _do():
|
|
2429
|
+
r = await cdp_send(ws_url, [(1, "Browser.getWindowForTarget", {})])
|
|
2430
|
+
win_id = ((r.get(1) or {}).get("result") or {}).get("windowId")
|
|
2431
|
+
if win_id is None:
|
|
2432
|
+
win_id = 1
|
|
2433
|
+
await cdp_send(ws_url, [(2, "Browser.setWindowBounds", {
|
|
2434
|
+
"windowId": win_id,
|
|
2435
|
+
"bounds": {"windowState": "minimized"},
|
|
2436
|
+
})])
|
|
2437
|
+
|
|
2438
|
+
asyncio.run(_do())
|
|
2439
|
+
|
|
2440
|
+
|
|
2015
2441
|
def cmd_launch():
|
|
2016
2442
|
"""Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
|
|
2017
2443
|
global CHROME_BIN, CDP_PORT, CDP_BASE
|
|
@@ -2098,6 +2524,12 @@ def cmd_launch():
|
|
|
2098
2524
|
'InterestFeedContentSuggestions', 'CalculateNativeWinOcclusion',
|
|
2099
2525
|
'PasswordManagerOnboarding', 'AutofillServerCommunication',
|
|
2100
2526
|
]),
|
|
2527
|
+
# Anti-bot leak fix (2026-05-31): Chrome's blink runtime exposes an
|
|
2528
|
+
# "automation controlled" flag when --remote-debugging-port is set;
|
|
2529
|
+
# Patchright/nodriver disable this. Even though navigator.webdriver
|
|
2530
|
+
# already reads false on vanilla Brave/Chrome, deeper detectors
|
|
2531
|
+
# (Cloudflare/Datadome behavioral) probe the blink flag directly.
|
|
2532
|
+
'--disable-blink-features=AutomationControlled',
|
|
2101
2533
|
# ─── Chromium performance flags ───
|
|
2102
2534
|
'--disable-background-networking',
|
|
2103
2535
|
'--disable-background-timer-throttling',
|
|
@@ -2128,6 +2560,17 @@ def cmd_launch():
|
|
|
2128
2560
|
'--new-window', 'about:blank',
|
|
2129
2561
|
]
|
|
2130
2562
|
|
|
2563
|
+
# Off-screen window placement (opt-in). Keeps the browser HEADED (anti-bot
|
|
2564
|
+
# detection still sees a real window / GPU) but parks it far off the visible
|
|
2565
|
+
# desktop so it never steals focus or pops in front of the user — ideal for
|
|
2566
|
+
# long bench runs on a workstation. CDPILOT_OFFSCREEN=1 or a custom
|
|
2567
|
+
# CDPILOT_WINDOW_POSITION="x,y". No effect on headless.
|
|
2568
|
+
_win_pos = os.environ.get('CDPILOT_WINDOW_POSITION', '')
|
|
2569
|
+
if not _win_pos and os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
|
|
2570
|
+
_win_pos = '-3000,-3000'
|
|
2571
|
+
if _win_pos:
|
|
2572
|
+
chrome_args.append(f'--window-position={_win_pos}')
|
|
2573
|
+
|
|
2131
2574
|
# Dev extensions
|
|
2132
2575
|
dev_exts = get_dev_extensions()
|
|
2133
2576
|
valid_exts = [p for p in dev_exts if os.path.isdir(p)]
|
|
@@ -2154,6 +2597,16 @@ def cmd_launch():
|
|
|
2154
2597
|
if cdp_get('/json/version'):
|
|
2155
2598
|
if PROJECT_ID:
|
|
2156
2599
|
_register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
|
|
2600
|
+
# Off-screen / minimized placement (opt-in). Keeps the browser
|
|
2601
|
+
# HEADED (anti-bot still sees a real GPU-backed window) but parks it
|
|
2602
|
+
# out of the way so long bench runs never steal focus or pop in
|
|
2603
|
+
# front of the user. macOS clamps negative --window-position, so we
|
|
2604
|
+
# also minimize via CDP (Browser.setWindowBounds) which is reliable.
|
|
2605
|
+
if os.environ.get('CDPILOT_OFFSCREEN') in ('1', 'true', 'yes', 'on'):
|
|
2606
|
+
try:
|
|
2607
|
+
_minimize_browser_window()
|
|
2608
|
+
except Exception:
|
|
2609
|
+
pass
|
|
2157
2610
|
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
2158
2611
|
print(f'CDP ready! (port {CDP_PORT}){proj_label}')
|
|
2159
2612
|
return
|
|
@@ -2161,7 +2614,16 @@ def cmd_launch():
|
|
|
2161
2614
|
sys.exit(1)
|
|
2162
2615
|
|
|
2163
2616
|
|
|
2164
|
-
def cmd_tabs():
|
|
2617
|
+
def cmd_tabs(reap=False, max_tabs=1, include_real=False, dry_run=False):
|
|
2618
|
+
if reap:
|
|
2619
|
+
result = _reap_tabs(max_tabs=max_tabs, include_real=include_real,
|
|
2620
|
+
dry_run=dry_run)
|
|
2621
|
+
verb = "would close" if result.get("dry_run") else "closed"
|
|
2622
|
+
print(f"{verb} {len(result['closed'])} tab(s), kept {result['kept']}"
|
|
2623
|
+
f" (blank {result.get('blank', 0)}, real {result.get('real', 0)})")
|
|
2624
|
+
if result.get("reason"):
|
|
2625
|
+
print(f" {result['reason']}")
|
|
2626
|
+
return
|
|
2165
2627
|
tabs = get_tabs()
|
|
2166
2628
|
pages = [t for t in tabs if t.get("type") == "page"]
|
|
2167
2629
|
for i, p in enumerate(pages):
|
|
@@ -2177,6 +2639,9 @@ async def cmd_go(url):
|
|
|
2177
2639
|
cmd_launch()
|
|
2178
2640
|
|
|
2179
2641
|
ws, page = get_page_ws()
|
|
2642
|
+
# Mark this tab as cdpilot-owned so a later `close` knows it can shut it.
|
|
2643
|
+
if isinstance(page, dict):
|
|
2644
|
+
_mark_owned_tab(page.get("id"))
|
|
2180
2645
|
|
|
2181
2646
|
try:
|
|
2182
2647
|
from urllib.parse import urlparse
|
|
@@ -2188,12 +2653,20 @@ async def cmd_go(url):
|
|
|
2188
2653
|
adaptive_stealth = False
|
|
2189
2654
|
is_known_hostile = _adaptive_host_requires_stealth(url)
|
|
2190
2655
|
|
|
2191
|
-
# Adaptive mode: if
|
|
2192
|
-
#
|
|
2193
|
-
|
|
2194
|
-
|
|
2656
|
+
# Adaptive mode (tier-aware): if we learned a tier for this host, start the
|
|
2657
|
+
# navigation at that tier via CDPILOT_MODE. Falls back to the legacy
|
|
2658
|
+
# stealth_hosts list (-> 'stealth' tier) for hosts learned before tiers
|
|
2659
|
+
# existed. CDPILOT_MODE takes precedence in get_mode_config().
|
|
2660
|
+
learned_tier = _adaptive_host_tier(url)
|
|
2661
|
+
if learned_tier and learned_tier != 'regular' and get_mode_config() == 'regular':
|
|
2662
|
+
os.environ['CDPILOT_MODE'] = learned_tier
|
|
2195
2663
|
adaptive_stealth = True
|
|
2196
|
-
sys.stderr.write(f"🛡️ Adaptive:
|
|
2664
|
+
sys.stderr.write(f"🛡️ Adaptive: starting at learned tier '{learned_tier}' for known-hostile host\n")
|
|
2665
|
+
elif is_known_hostile and get_mode_config() == 'regular':
|
|
2666
|
+
# Legacy stealth_hosts entry with no tier learned yet — start at light.
|
|
2667
|
+
os.environ['CDPILOT_MODE'] = 'stealth'
|
|
2668
|
+
adaptive_stealth = True
|
|
2669
|
+
sys.stderr.write(f"🛡️ Adaptive: enabling stealth tier for known-hostile host\n")
|
|
2197
2670
|
|
|
2198
2671
|
# Fix 1: Per-task context isolation (opt-in via CDPILOT_ADAPTIVE_FRESH_CONTEXT=1) — spawn a fresh BrowserContext when
|
|
2199
2672
|
# adaptive is on and we know this host is hostile. Prevents cross-task
|
|
@@ -2231,6 +2704,44 @@ async def cmd_go(url):
|
|
|
2231
2704
|
except Exception:
|
|
2232
2705
|
pass
|
|
2233
2706
|
|
|
2707
|
+
# Progressive-resilience friction probe (non-captcha rungs).
|
|
2708
|
+
# rate_limited -> exponential backoff + re-nav (bounded). login/otp/hard
|
|
2709
|
+
# -> stderr handoff warning only (NO autonomous bypass — ethics line).
|
|
2710
|
+
# The captcha rung is left to the dedicated probe below.
|
|
2711
|
+
try:
|
|
2712
|
+
fr = await _detect_friction(active_ws)
|
|
2713
|
+
fr_level = fr.get('level', 'none')
|
|
2714
|
+
if fr_level == 'rate_limited' and _friction_backoff_enabled():
|
|
2715
|
+
attempt = 0
|
|
2716
|
+
while fr_level == 'rate_limited' and attempt < _friction_max_retry():
|
|
2717
|
+
wait_s = _friction_backoff_seconds(attempt)
|
|
2718
|
+
sys.stderr.write(
|
|
2719
|
+
f"⏳ Rate-limit tespit edildi ({','.join(fr.get('signals', [])) or '?'}); "
|
|
2720
|
+
f"{wait_s}s backoff, retry {attempt + 1}/{_friction_max_retry()}...\n")
|
|
2721
|
+
if expected_host:
|
|
2722
|
+
_adaptive_remember_host(expected_host)
|
|
2723
|
+
await asyncio.sleep(wait_s)
|
|
2724
|
+
await navigate_collect(active_ws, url)
|
|
2725
|
+
fr = await _detect_friction(active_ws)
|
|
2726
|
+
fr_level = fr.get('level', 'none')
|
|
2727
|
+
attempt += 1
|
|
2728
|
+
if fr_level == 'rate_limited':
|
|
2729
|
+
sys.stderr.write("⚠️ Rate-limit backoff sonrası hâlâ sınırlı. Daha sonra deneyin.\n")
|
|
2730
|
+
else:
|
|
2731
|
+
sys.stderr.write("✅ Rate-limit temizlendi.\n")
|
|
2732
|
+
elif fr_level in ('login_wall', 'otp_sms', 'hard_block'):
|
|
2733
|
+
act = _friction_action(fr_level)
|
|
2734
|
+
payload = {'action': act['action'], 'url': url, 'level': fr_level,
|
|
2735
|
+
'signals': fr.get('signals', [])}
|
|
2736
|
+
if act.get('backoff_suggested'):
|
|
2737
|
+
payload['backoff_suggested'] = True
|
|
2738
|
+
# Human handoff: warn + emit JSON line, but DO NOT block the
|
|
2739
|
+
# navigation (caller may still want the partial content).
|
|
2740
|
+
sys.stderr.write(f"{act['message']}\n")
|
|
2741
|
+
sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
2742
|
+
except Exception:
|
|
2743
|
+
pass
|
|
2744
|
+
|
|
2234
2745
|
# Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
|
|
2235
2746
|
try:
|
|
2236
2747
|
info = await _detect_captcha(active_ws)
|
|
@@ -2238,20 +2749,26 @@ async def cmd_go(url):
|
|
|
2238
2749
|
types = ",".join(info.get("types", [])) or "unknown"
|
|
2239
2750
|
sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
|
|
2240
2751
|
|
|
2241
|
-
# Adaptive escalation:
|
|
2242
|
-
#
|
|
2752
|
+
# Adaptive escalation (tier-aware): bump the host one tier up
|
|
2753
|
+
# (regular -> stealth -> undetected), remember it, and re-nav
|
|
2754
|
+
# once at the new tier. Re-nav is gated to once-per-call.
|
|
2243
2755
|
if cfg['enabled']:
|
|
2756
|
+
current_tier = get_mode_config()
|
|
2757
|
+
next_tier = _escalate_tier(current_tier)
|
|
2244
2758
|
if expected_host:
|
|
2245
2759
|
_adaptive_remember_host(expected_host)
|
|
2246
|
-
|
|
2760
|
+
_adaptive_remember_host_tier(expected_host, next_tier)
|
|
2761
|
+
sys.stderr.write(f"🛡️ Adaptive: remembered {expected_host} at tier '{next_tier}'\n")
|
|
2247
2762
|
captcha_types = info.get('types', [])
|
|
2248
2763
|
_adaptive_remember_host_entropy(expected_host, captcha_types)
|
|
2249
2764
|
needs_entropy = any(CAPTCHA_ENTROPY_REQUIRED.get(t, True) for t in captcha_types)
|
|
2250
2765
|
if needs_entropy:
|
|
2251
2766
|
sys.stderr.write(f"🧬 Adaptive: per-host entropy enabled for {expected_host} ({','.join(captcha_types)})\n")
|
|
2252
|
-
if
|
|
2253
|
-
|
|
2254
|
-
|
|
2767
|
+
# Only retry if escalation actually changes the tier (not
|
|
2768
|
+
# already at the undetected ceiling for this navigation).
|
|
2769
|
+
if next_tier != current_tier:
|
|
2770
|
+
sys.stderr.write(f"🛡️ Adaptive: retrying once at tier '{next_tier}'...\n")
|
|
2771
|
+
os.environ['CDPILOT_MODE'] = next_tier
|
|
2255
2772
|
# Fix 3: Idempotent re-nav — skip if already on target.
|
|
2256
2773
|
current_host = await _adaptive_current_host(active_ws)
|
|
2257
2774
|
norm = lambda h: h[4:] if h.startswith("www.") else h
|
|
@@ -2259,12 +2776,12 @@ async def cmd_go(url):
|
|
|
2259
2776
|
sys.stderr.write("🛡️ Adaptive: already on target host, skip re-nav\n")
|
|
2260
2777
|
else:
|
|
2261
2778
|
await navigate_collect(active_ws, url)
|
|
2262
|
-
# Re-probe after
|
|
2779
|
+
# Re-probe after escalated re-nav
|
|
2263
2780
|
info2 = await _detect_captcha(active_ws)
|
|
2264
2781
|
if info2.get("detected"):
|
|
2265
|
-
sys.stderr.write("⚠️ Adaptive: CAPTCHA still present after
|
|
2782
|
+
sys.stderr.write(f"⚠️ Adaptive: CAPTCHA still present after '{next_tier}' retry. Manual solve needed.\n")
|
|
2266
2783
|
else:
|
|
2267
|
-
sys.stderr.write("✅ Adaptive: CAPTCHA cleared
|
|
2784
|
+
sys.stderr.write(f"✅ Adaptive: CAPTCHA cleared at tier '{next_tier}'.\n")
|
|
2268
2785
|
# Captcha solver auto-mode: if provider configured + auto on, attempt solve
|
|
2269
2786
|
try:
|
|
2270
2787
|
await _captcha_auto_solve_if_enabled(active_ws, info, url)
|
|
@@ -2773,6 +3290,188 @@ async def _humanize_click(ws_url, x, y):
|
|
|
2773
3290
|
await asyncio.sleep(r.uniform(0.08, 0.20))
|
|
2774
3291
|
|
|
2775
3292
|
|
|
3293
|
+
# ─── PerimeterX / HUMAN "Press & Hold" solver ───────────────────────────────
|
|
3294
|
+
# PerimeterX (HUMAN Security) gates content behind a button the user must press
|
|
3295
|
+
# AND HOLD for several seconds. The detector measures the mouse-hold *behaviour*
|
|
3296
|
+
# (press duration, micro-tremor during the hold, release timing) to tell a human
|
|
3297
|
+
# hand from a bot — it is NOT a token challenge, so there is nothing to fetch or
|
|
3298
|
+
# inject. The only way through is to emit a real, human-shaped press->hold->release
|
|
3299
|
+
# gesture. We reuse the existing humanized mouse path + Input.dispatchMouseEvent
|
|
3300
|
+
# (zero new deps, pure CDP) and add the natural hand-tremor PerimeterX looks for.
|
|
3301
|
+
|
|
3302
|
+
# JS that locates the press-and-hold target and returns its viewport centre.
|
|
3303
|
+
# Tries the canonical #px-captcha / [class*="px-captcha"] hooks first, then any
|
|
3304
|
+
# element whose visible text matches "press & hold" / "press and hold" / the
|
|
3305
|
+
# Turkish "basili tut". Returns JSON: {found, cx, cy, w, h, how} or {found:false}.
|
|
3306
|
+
PRESS_HOLD_FIND_JS = r"""
|
|
3307
|
+
(function(userSel) {
|
|
3308
|
+
try {
|
|
3309
|
+
function centre(el, how) {
|
|
3310
|
+
if (!el) return null;
|
|
3311
|
+
try { el.scrollIntoView({behavior: 'instant', block: 'center'}); } catch (e) {}
|
|
3312
|
+
var r = el.getBoundingClientRect();
|
|
3313
|
+
if (r.width < 1 || r.height < 1) return null;
|
|
3314
|
+
return JSON.stringify({
|
|
3315
|
+
found: true,
|
|
3316
|
+
cx: Math.round(r.left + r.width / 2),
|
|
3317
|
+
cy: Math.round(r.top + r.height / 2),
|
|
3318
|
+
w: Math.round(r.width), h: Math.round(r.height), how: how
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
if (userSel) {
|
|
3322
|
+
var u = document.querySelector(userSel);
|
|
3323
|
+
var cu = centre(u, 'user-selector');
|
|
3324
|
+
if (cu) return cu;
|
|
3325
|
+
}
|
|
3326
|
+
// Canonical PerimeterX hooks.
|
|
3327
|
+
var px = document.querySelector('#px-captcha, [class*="px-captcha"]');
|
|
3328
|
+
if (px) {
|
|
3329
|
+
// The visible hold target is often an inner button/div; prefer a child
|
|
3330
|
+
// with real size, else the container itself.
|
|
3331
|
+
var inner = px.querySelector('div[role="button"], button, [tabindex], div');
|
|
3332
|
+
var ci = centre(inner, 'px-inner');
|
|
3333
|
+
if (ci) return ci;
|
|
3334
|
+
var cc = centre(px, 'px-container');
|
|
3335
|
+
if (cc) return cc;
|
|
3336
|
+
}
|
|
3337
|
+
// Text-based fallback: any small interactive element saying press & hold.
|
|
3338
|
+
var needles = ['press & hold', 'press and hold', 'press & hold',
|
|
3339
|
+
'basili tut', 'basılı tut', 'press hold'];
|
|
3340
|
+
var cands = document.querySelectorAll(
|
|
3341
|
+
'button, [role="button"], div, span, a, p');
|
|
3342
|
+
for (var i = 0; i < cands.length; i++) {
|
|
3343
|
+
var el = cands[i];
|
|
3344
|
+
var t = (el.textContent || '').toLowerCase().trim();
|
|
3345
|
+
if (!t || t.length > 60) continue;
|
|
3346
|
+
for (var j = 0; j < needles.length; j++) {
|
|
3347
|
+
if (t.indexOf(needles[j]) >= 0) {
|
|
3348
|
+
var ct = centre(el, 'text-match');
|
|
3349
|
+
if (ct) return ct;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
return JSON.stringify({ found: false });
|
|
3354
|
+
} catch (e) {
|
|
3355
|
+
return JSON.stringify({ found: false, error: String(e) });
|
|
3356
|
+
}
|
|
3357
|
+
})
|
|
3358
|
+
"""
|
|
3359
|
+
|
|
3360
|
+
|
|
3361
|
+
def _press_hold_duration_ms():
|
|
3362
|
+
"""Gaussian-randomized hold duration in ms. mu~4000, clamped 3000..7000.
|
|
3363
|
+
|
|
3364
|
+
PerimeterX rejects a fixed duration (machine-like). We draw from a Gaussian
|
|
3365
|
+
so each attempt looks individually human, then clamp to the band the widget
|
|
3366
|
+
actually accepts.
|
|
3367
|
+
"""
|
|
3368
|
+
return int(_gauss(4000, 900, 3000, 7000))
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
async def _press_hold_gesture(ws_url, x, y, hold_ms):
|
|
3372
|
+
"""Emit one real press->hold(jitter)->release gesture at (x, y) over CDP.
|
|
3373
|
+
|
|
3374
|
+
1. Bezier-humanized mouse move to the target (reuses _humanize_mouse_move).
|
|
3375
|
+
2. Input.dispatchMouseEvent mousePressed (button:left) at the target.
|
|
3376
|
+
3. Hold loop: every 100-200ms emit a mouseMoved with +/-1-2px jitter WHILE the
|
|
3377
|
+
left button stays pressed -- this is the natural hand-tremor PerimeterX
|
|
3378
|
+
inspects. Total loop time == hold_ms.
|
|
3379
|
+
4. Input.dispatchMouseEvent mouseReleased.
|
|
3380
|
+
Returns the number of micro-jitter moves dispatched during the hold.
|
|
3381
|
+
"""
|
|
3382
|
+
import random as _r
|
|
3383
|
+
r = _r.Random(int(_ENTROPY_SEED)) if _ENTROPY_SEED else _r.Random()
|
|
3384
|
+
# Land the cursor on the target with a human path + small final offset.
|
|
3385
|
+
await _humanize_mouse_move(ws_url, x, y)
|
|
3386
|
+
await asyncio.sleep(r.uniform(0.05, 0.18))
|
|
3387
|
+
# Press and hold the left button at the target.
|
|
3388
|
+
await cdp_send(ws_url, [(981, "Input.dispatchMouseEvent", {
|
|
3389
|
+
"type": "mousePressed", "x": x, "y": y,
|
|
3390
|
+
"button": "left", "buttons": 1, "clickCount": 1})])
|
|
3391
|
+
jitters = 0
|
|
3392
|
+
elapsed = 0.0
|
|
3393
|
+
cur_x, cur_y = x, y
|
|
3394
|
+
hold_s = hold_ms / 1000.0
|
|
3395
|
+
while elapsed < hold_s:
|
|
3396
|
+
step = r.uniform(0.10, 0.20)
|
|
3397
|
+
# Don't overshoot the requested hold window.
|
|
3398
|
+
if elapsed + step > hold_s:
|
|
3399
|
+
step = max(0.0, hold_s - elapsed)
|
|
3400
|
+
await asyncio.sleep(step)
|
|
3401
|
+
elapsed += step
|
|
3402
|
+
if elapsed >= hold_s:
|
|
3403
|
+
break
|
|
3404
|
+
# +/-1-2px tremor around the press point, button still held (buttons:1).
|
|
3405
|
+
cur_x = x + r.randint(-2, 2)
|
|
3406
|
+
cur_y = y + r.randint(-2, 2)
|
|
3407
|
+
await cdp_send(ws_url, [(982, "Input.dispatchMouseEvent", {
|
|
3408
|
+
"type": "mouseMoved", "x": cur_x, "y": cur_y,
|
|
3409
|
+
"button": "left", "buttons": 1, "modifiers": 0})])
|
|
3410
|
+
jitters += 1
|
|
3411
|
+
# Release at (near) the press point.
|
|
3412
|
+
await cdp_send(ws_url, [(983, "Input.dispatchMouseEvent", {
|
|
3413
|
+
"type": "mouseReleased", "x": cur_x, "y": cur_y,
|
|
3414
|
+
"button": "left", "buttons": 0, "clickCount": 1})])
|
|
3415
|
+
return jitters
|
|
3416
|
+
|
|
3417
|
+
|
|
3418
|
+
async def _solve_press_and_hold(ws_url, target_sel=None):
|
|
3419
|
+
"""Solve a PerimeterX/HUMAN "Press & Hold" challenge with a humanized gesture.
|
|
3420
|
+
|
|
3421
|
+
Finds the hold target (#px-captcha / [class*="px-captcha"] / press-and-hold
|
|
3422
|
+
text, or an explicit target_sel), then performs press->hold(with micro-tremor)
|
|
3423
|
+
->release. Verifies via _detect_captcha; retries once with a different hold
|
|
3424
|
+
duration if the challenge is still present.
|
|
3425
|
+
|
|
3426
|
+
Returns: {"solved": bool, "method": "press_and_hold", "hold_ms": N,
|
|
3427
|
+
"attempts": N, ...}. Never raises.
|
|
3428
|
+
"""
|
|
3429
|
+
last_hold = 0
|
|
3430
|
+
attempts = 0
|
|
3431
|
+
max_attempts = 2
|
|
3432
|
+
for attempts in range(1, max_attempts + 1):
|
|
3433
|
+
# (Re)locate the target each attempt -- the widget may re-render.
|
|
3434
|
+
try:
|
|
3435
|
+
find_expr = f"({PRESS_HOLD_FIND_JS})({json.dumps(target_sel) if target_sel else 'null'})"
|
|
3436
|
+
res = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
3437
|
+
"expression": find_expr, "returnByValue": True})], timeout=8)
|
|
3438
|
+
raw = res.get(1, {}).get("result", {}).get("value")
|
|
3439
|
+
loc = json.loads(raw) if raw else {"found": False}
|
|
3440
|
+
except Exception as e:
|
|
3441
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3442
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3443
|
+
"error": f"locate_failed:{str(e)[:80]}"}
|
|
3444
|
+
if not loc.get("found"):
|
|
3445
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3446
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3447
|
+
"error": "target_not_found"}
|
|
3448
|
+
|
|
3449
|
+
hold_ms = _press_hold_duration_ms()
|
|
3450
|
+
last_hold = hold_ms
|
|
3451
|
+
try:
|
|
3452
|
+
jitters = await _press_hold_gesture(
|
|
3453
|
+
ws_url, int(loc["cx"]), int(loc["cy"]), hold_ms)
|
|
3454
|
+
except Exception as e:
|
|
3455
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3456
|
+
"hold_ms": hold_ms, "attempts": attempts,
|
|
3457
|
+
"error": f"gesture_failed:{str(e)[:80]}"}
|
|
3458
|
+
|
|
3459
|
+
# Settle, then check whether the challenge cleared.
|
|
3460
|
+
import random as _r
|
|
3461
|
+
await asyncio.sleep((_r.Random().uniform(500, 1500)) / 1000.0)
|
|
3462
|
+
info = await _detect_captcha(ws_url)
|
|
3463
|
+
still_px = 'perimeterx' in (info.get('types') or [])
|
|
3464
|
+
if not still_px:
|
|
3465
|
+
return {"solved": True, "method": "press_and_hold",
|
|
3466
|
+
"hold_ms": hold_ms, "attempts": attempts,
|
|
3467
|
+
"jitter_moves": jitters}
|
|
3468
|
+
# else: loop and try once more with a fresh (re-drawn) hold duration.
|
|
3469
|
+
|
|
3470
|
+
return {"solved": False, "method": "press_and_hold",
|
|
3471
|
+
"hold_ms": last_hold, "attempts": attempts,
|
|
3472
|
+
"error": "still_present_after_retry"}
|
|
3473
|
+
|
|
3474
|
+
|
|
2776
3475
|
async def _humanize_type(ws_url, text):
|
|
2777
3476
|
"""Type text with Gaussian inter-key and dwell delays via CDP key events."""
|
|
2778
3477
|
import random as _r
|
|
@@ -3873,72 +4572,301 @@ async def cmd_debug(url=None):
|
|
|
3873
4572
|
print(f"Debug complete: {url}")
|
|
3874
4573
|
|
|
3875
4574
|
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
4575
|
+
# ─── Owned-Tab Tracking ───
|
|
4576
|
+
# cdpilot records every target_id it opens so that `close` can distinguish its
|
|
4577
|
+
# own tabs from tabs the user opened manually in the same isolated profile.
|
|
4578
|
+
# Stored as a JSON list in the project profile dir; survives across CLI
|
|
4579
|
+
# invocations (each `go`/`new-tab` is a separate process).
|
|
3880
4580
|
|
|
4581
|
+
def _load_owned_tabs():
|
|
4582
|
+
"""Return the set of target_ids cdpilot opened in this project profile."""
|
|
4583
|
+
try:
|
|
4584
|
+
with open(OWNED_TABS_FILE) as f:
|
|
4585
|
+
data = json.load(f)
|
|
4586
|
+
return set(data.get("owned", []))
|
|
4587
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
4588
|
+
return set()
|
|
3881
4589
|
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
# Check if target is still active
|
|
3892
|
-
tabs = cdp_get("/json") or []
|
|
3893
|
-
active = any(t.get("id") == info.get("target_id") for t in tabs)
|
|
3894
|
-
print(f" Status: {'active' if active else 'gone (will be recreated)'}")
|
|
3895
|
-
else:
|
|
3896
|
-
print(f"Session: {sid}")
|
|
3897
|
-
print(" No window assigned yet (will be created on first command)")
|
|
4590
|
+
|
|
4591
|
+
def _save_owned_tabs(owned):
|
|
4592
|
+
"""Persist the owned target_id set."""
|
|
4593
|
+
try:
|
|
4594
|
+
os.makedirs(os.path.dirname(OWNED_TABS_FILE), exist_ok=True)
|
|
4595
|
+
with open(OWNED_TABS_FILE, "w") as f:
|
|
4596
|
+
json.dump({"owned": sorted(owned)}, f)
|
|
4597
|
+
except OSError:
|
|
4598
|
+
pass
|
|
3898
4599
|
|
|
3899
4600
|
|
|
3900
|
-
def
|
|
3901
|
-
"""
|
|
3902
|
-
|
|
3903
|
-
if not sessions:
|
|
3904
|
-
print("No active sessions.")
|
|
4601
|
+
def _mark_owned_tab(target_id):
|
|
4602
|
+
"""Record a target_id as cdpilot-owned. No-op on falsy input."""
|
|
4603
|
+
if not target_id:
|
|
3905
4604
|
return
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
print("─" * 100)
|
|
3911
|
-
for sid, info in sessions.items():
|
|
3912
|
-
tid = info.get("target_id", "?")
|
|
3913
|
-
created = info.get("created", "?")
|
|
3914
|
-
active = "active" if tid in active_ids else "gone"
|
|
3915
|
-
marker = " ← (current)" if sid == current_sid else ""
|
|
3916
|
-
print(f"{sid:<30} {tid:<40} {active:<8} {created}{marker}")
|
|
3917
|
-
print(f"\nTotal: {len(sessions)} sessions")
|
|
4605
|
+
owned = _load_owned_tabs()
|
|
4606
|
+
if target_id not in owned:
|
|
4607
|
+
owned.add(target_id)
|
|
4608
|
+
_save_owned_tabs(owned)
|
|
3918
4609
|
|
|
3919
4610
|
|
|
3920
|
-
def
|
|
3921
|
-
"""
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
4611
|
+
def _is_chrome_internal_url(url):
|
|
4612
|
+
"""True for blank/new-tab/internal pages that don't count as 'real' tabs."""
|
|
4613
|
+
if not url:
|
|
4614
|
+
return True
|
|
4615
|
+
u = url.strip().lower()
|
|
4616
|
+
return (u in ("about:blank", "about:newtab", "")
|
|
4617
|
+
or u.startswith("chrome://")
|
|
4618
|
+
or u.startswith("edge://")
|
|
4619
|
+
or u.startswith("chrome-extension://")
|
|
4620
|
+
or u.startswith("devtools://")
|
|
4621
|
+
or u.startswith("brave://")
|
|
4622
|
+
or u.startswith("vivaldi://"))
|
|
4623
|
+
|
|
4624
|
+
|
|
4625
|
+
def _close_target(target_id):
|
|
4626
|
+
"""Close one CDP target. Returns True when the browser accepted it."""
|
|
4627
|
+
try:
|
|
4628
|
+
urllib.request.urlopen(f"{CDP_BASE}/json/close/{target_id}", timeout=2)
|
|
4629
|
+
return True
|
|
4630
|
+
except Exception:
|
|
4631
|
+
return False
|
|
3928
4632
|
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
4633
|
+
|
|
4634
|
+
def _reap_tabs(max_tabs=1, include_real=False, dry_run=False):
|
|
4635
|
+
"""Enforce a tab budget on the connected browser.
|
|
4636
|
+
|
|
4637
|
+
A long-lived browser accumulates abandoned tabs: every client that opens a
|
|
4638
|
+
target and exits without closing it leaks one, and nothing collects them.
|
|
4639
|
+
Measured on srv21 2026-08-30 — 513 pages, 506 of them about:blank, 12.7 GB
|
|
4640
|
+
across 529 renderer processes, growing ~6/hour for three days on a box with
|
|
4641
|
+
47 GB total.
|
|
4642
|
+
|
|
4643
|
+
Blank tabs go first: they hold nothing a caller could still want. Pages with
|
|
4644
|
+
a real URL are never touched unless include_real is set, because on a shared
|
|
4645
|
+
browser they may belong to somebody else. The session's own tab and the last
|
|
4646
|
+
remaining page always survive.
|
|
4647
|
+
"""
|
|
4648
|
+
tabs = cdp_get("/json", no_cache=True)
|
|
4649
|
+
if not tabs:
|
|
4650
|
+
return {"closed": [], "kept": 0, "reason": "no CDP connection"}
|
|
4651
|
+
pages = [t for t in tabs if t.get("type") == "page"]
|
|
4652
|
+
if len(pages) <= 1:
|
|
4653
|
+
return {"closed": [], "kept": len(pages), "reason": "nothing to reap"}
|
|
4654
|
+
|
|
4655
|
+
session_id = _get_session_window_target_id()
|
|
4656
|
+
real, blank = [], []
|
|
4657
|
+
for t in pages:
|
|
4658
|
+
(blank if _is_chrome_internal_url(t.get("url")) else real).append(t)
|
|
4659
|
+
|
|
4660
|
+
keep_ids = []
|
|
4661
|
+
if session_id and any(t.get("id") == session_id for t in pages):
|
|
4662
|
+
keep_ids.append(session_id)
|
|
4663
|
+
if not include_real:
|
|
4664
|
+
keep_ids += [t["id"] for t in real if t["id"] not in keep_ids]
|
|
4665
|
+
for t in blank:
|
|
4666
|
+
if len(keep_ids) >= max_tabs:
|
|
4667
|
+
break
|
|
4668
|
+
if t["id"] not in keep_ids:
|
|
4669
|
+
keep_ids.append(t["id"])
|
|
4670
|
+
if not keep_ids: # never leave the browser with zero pages
|
|
4671
|
+
keep_ids.append(pages[0]["id"])
|
|
4672
|
+
|
|
4673
|
+
doomed = [t for t in pages if t["id"] not in keep_ids]
|
|
4674
|
+
closed = []
|
|
4675
|
+
for t in doomed:
|
|
4676
|
+
if dry_run or _close_target(t["id"]):
|
|
4677
|
+
closed.append(t["id"])
|
|
4678
|
+
if closed and not dry_run:
|
|
4679
|
+
cdp_cache_invalidate()
|
|
4680
|
+
owned = _load_owned_tabs()
|
|
4681
|
+
if owned & set(closed):
|
|
4682
|
+
_save_owned_tabs(owned - set(closed))
|
|
4683
|
+
return {"closed": closed, "kept": len(keep_ids),
|
|
4684
|
+
"blank": len(blank), "real": len(real), "dry_run": dry_run}
|
|
4685
|
+
|
|
4686
|
+
|
|
4687
|
+
def _enforce_tab_budget():
|
|
4688
|
+
"""Opt-in post-command reaping via CDPILOT_MAX_TABS.
|
|
4689
|
+
|
|
4690
|
+
Off unless the variable is set: silently closing tabs would be hostile on a
|
|
4691
|
+
desktop where the browser is also the user's. On an unattended server it is
|
|
4692
|
+
the difference between one tab and five hundred.
|
|
4693
|
+
"""
|
|
4694
|
+
raw = os.environ.get("CDPILOT_MAX_TABS", "").strip()
|
|
4695
|
+
if not raw.isdigit() or int(raw) < 1:
|
|
4696
|
+
return
|
|
4697
|
+
try:
|
|
4698
|
+
result = _reap_tabs(max_tabs=int(raw))
|
|
4699
|
+
except Exception:
|
|
4700
|
+
return
|
|
4701
|
+
if result.get("closed"):
|
|
4702
|
+
print(f"[cdpilot] tab budget: closed {len(result['closed'])} idle tab(s)",
|
|
4703
|
+
file=sys.stderr)
|
|
4704
|
+
|
|
4705
|
+
|
|
4706
|
+
async def _browser_close_graceful():
|
|
4707
|
+
"""Ask the browser to shut down gracefully via the CDP Browser.close
|
|
4708
|
+
command (cleanest cross-platform path — flushes state, no orphaned procs).
|
|
4709
|
+
Returns True if the command was acknowledged. Falls back to a SIGTERM-based
|
|
4710
|
+
process stop (never kill -9) when the WebSocket path fails."""
|
|
4711
|
+
try:
|
|
4712
|
+
browser_ws = await _get_browser_ws()
|
|
4713
|
+
if browser_ws:
|
|
4714
|
+
await cdp_send(browser_ws, [(1, "Browser.close", {})], timeout=5)
|
|
4715
|
+
return True
|
|
4716
|
+
except SystemExit:
|
|
4717
|
+
raise
|
|
4718
|
+
except Exception:
|
|
4719
|
+
pass
|
|
4720
|
+
# Fallback: graceful OS-level termination (SIGTERM on POSIX, taskkill on
|
|
4721
|
+
# Windows). kill -9 is intentionally NOT used here — last resort only.
|
|
4722
|
+
return _stop_browser_on_port(CDP_PORT)
|
|
4723
|
+
|
|
4724
|
+
|
|
4725
|
+
async def cmd_close(force_browser=False, keep_browser=False):
|
|
4726
|
+
"""Smart close: shut down cdpilot's own tabs, then close the whole browser
|
|
4727
|
+
if no user tabs remain.
|
|
4728
|
+
|
|
4729
|
+
Behavior:
|
|
4730
|
+
1. Close every cdpilot-owned tab (CDP Target.closeTarget).
|
|
4731
|
+
2. Inspect the remaining page targets:
|
|
4732
|
+
- 0 real tabs left (only about:blank / chrome:// / new-tab) ->
|
|
4733
|
+
close the browser application gracefully (Browser.close, SIGTERM
|
|
4734
|
+
fallback — never kill -9).
|
|
4735
|
+
- one or more user tabs remain -> leave the browser open; only the
|
|
4736
|
+
owned tabs were closed.
|
|
4737
|
+
|
|
4738
|
+
Flags:
|
|
4739
|
+
keep_browser — never close the browser, only the owned tabs.
|
|
4740
|
+
force_browser — close the browser even if user tabs remain.
|
|
4741
|
+
"""
|
|
4742
|
+
if not cdp_get("/json/version"):
|
|
4743
|
+
print("No browser running.")
|
|
4744
|
+
return
|
|
4745
|
+
|
|
4746
|
+
tabs = get_tabs()
|
|
4747
|
+
pages = [t for t in tabs if t.get("type") == "page"]
|
|
4748
|
+
owned = _load_owned_tabs()
|
|
4749
|
+
|
|
4750
|
+
# Make sure the current session window counts as owned even if it predates
|
|
4751
|
+
# tracking (legacy session created before this feature shipped).
|
|
4752
|
+
session_target = _get_session_window_target_id()
|
|
4753
|
+
if session_target:
|
|
4754
|
+
owned.add(session_target)
|
|
4755
|
+
|
|
4756
|
+
browser_ws = await _get_browser_ws()
|
|
4757
|
+
closed = 0
|
|
4758
|
+
for p in pages:
|
|
4759
|
+
tid = p.get("id")
|
|
4760
|
+
if tid and tid in owned:
|
|
4761
|
+
try:
|
|
4762
|
+
await cdp_send(browser_ws, [(1, "Target.closeTarget",
|
|
4763
|
+
{"targetId": tid})], timeout=5)
|
|
4764
|
+
closed += 1
|
|
4765
|
+
except Exception:
|
|
4766
|
+
pass
|
|
4767
|
+
|
|
4768
|
+
# Owned set is consumed — clear tracking + the CWD session window pointer so
|
|
4769
|
+
# a later command starts clean.
|
|
4770
|
+
_save_owned_tabs(set())
|
|
4771
|
+
if session_target:
|
|
4772
|
+
sessions = _load_sessions()
|
|
4773
|
+
sid = _get_session_id()
|
|
4774
|
+
sessions.pop(sid, None)
|
|
4775
|
+
_save_sessions(sessions)
|
|
4776
|
+
cdp_cache_invalidate()
|
|
4777
|
+
|
|
4778
|
+
print(f"Closed {closed} cdpilot tab(s).")
|
|
4779
|
+
|
|
4780
|
+
# Re-read remaining targets after closing owned tabs.
|
|
4781
|
+
remaining = get_tabs()
|
|
4782
|
+
remaining_pages = [t for t in remaining if t.get("type") == "page"]
|
|
4783
|
+
user_pages = [p for p in remaining_pages
|
|
4784
|
+
if not _is_chrome_internal_url(p.get("url"))]
|
|
4785
|
+
|
|
4786
|
+
if keep_browser:
|
|
4787
|
+
if user_pages:
|
|
4788
|
+
print(f"Browser left open ({len(user_pages)} user tab(s)).")
|
|
4789
|
+
else:
|
|
4790
|
+
print("Browser left open (--keep).")
|
|
4791
|
+
return
|
|
4792
|
+
|
|
4793
|
+
if user_pages and not force_browser:
|
|
4794
|
+
print(f"Browser left open — {len(user_pages)} user tab(s) still present.")
|
|
4795
|
+
return
|
|
4796
|
+
|
|
4797
|
+
# No user tabs remain (or --force) -> close the browser gracefully.
|
|
4798
|
+
if await _browser_close_graceful():
|
|
4799
|
+
print(f"Browser closed gracefully (port {CDP_PORT}).")
|
|
4800
|
+
if PROJECT_ID:
|
|
4801
|
+
registry = _load_registry()
|
|
4802
|
+
if PROJECT_ID in registry:
|
|
4803
|
+
registry[PROJECT_ID]["status"] = "stopped"
|
|
4804
|
+
registry[PROJECT_ID]["pid"] = None
|
|
4805
|
+
_save_registry(registry)
|
|
4806
|
+
else:
|
|
4807
|
+
print("Browser close failed.", file=sys.stderr)
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def cmd_session():
|
|
4811
|
+
"""Show current session info."""
|
|
4812
|
+
sid = _get_session_id()
|
|
4813
|
+
sessions = _load_sessions()
|
|
4814
|
+
info = sessions.get(sid)
|
|
4815
|
+
if info:
|
|
4816
|
+
print(f"Session: {sid}")
|
|
4817
|
+
print(f" Target ID: {info.get('target_id', '?')}")
|
|
4818
|
+
print(f" Created: {info.get('created', '?')}")
|
|
4819
|
+
# Check if target is still active
|
|
4820
|
+
tabs = cdp_get("/json") or []
|
|
4821
|
+
active = any(t.get("id") == info.get("target_id") for t in tabs)
|
|
4822
|
+
print(f" Status: {'active' if active else 'gone (will be recreated)'}")
|
|
4823
|
+
else:
|
|
4824
|
+
print(f"Session: {sid}")
|
|
4825
|
+
print(" No window assigned yet (will be created on first command)")
|
|
4826
|
+
|
|
4827
|
+
|
|
4828
|
+
def cmd_sessions():
|
|
4829
|
+
"""List all active sessions."""
|
|
4830
|
+
sessions = _cleanup_stale_sessions()
|
|
4831
|
+
if not sessions:
|
|
4832
|
+
print("No active sessions.")
|
|
4833
|
+
return
|
|
4834
|
+
tabs = cdp_get("/json") or []
|
|
4835
|
+
active_ids = {t.get("id") for t in tabs}
|
|
4836
|
+
current_sid = _get_session_id()
|
|
4837
|
+
print(f"{'Session ID':<30} {'Target ID':<40} {'Status':<8} {'Created'}")
|
|
4838
|
+
print("─" * 100)
|
|
4839
|
+
for sid, info in sessions.items():
|
|
4840
|
+
tid = info.get("target_id", "?")
|
|
4841
|
+
created = info.get("created", "?")
|
|
4842
|
+
active = "active" if tid in active_ids else "gone"
|
|
4843
|
+
marker = " ← (current)" if sid == current_sid else ""
|
|
4844
|
+
print(f"{sid:<30} {tid:<40} {active:<8} {created}{marker}")
|
|
4845
|
+
print(f"\nTotal: {len(sessions)} sessions")
|
|
4846
|
+
|
|
4847
|
+
|
|
4848
|
+
def cmd_session_close(session_id=None):
|
|
4849
|
+
"""Close a specific session window and remove its registry entry."""
|
|
4850
|
+
sid = session_id or _get_session_id()
|
|
4851
|
+
sessions = _load_sessions()
|
|
4852
|
+
info = sessions.get(sid)
|
|
4853
|
+
if not info:
|
|
4854
|
+
print(f"Session not found: {sid}")
|
|
4855
|
+
return
|
|
4856
|
+
|
|
4857
|
+
target_id = info.get("target_id")
|
|
4858
|
+
if target_id:
|
|
4859
|
+
# Close the tab
|
|
4860
|
+
tabs = cdp_get("/json") or []
|
|
4861
|
+
for t in tabs:
|
|
4862
|
+
if t.get("id") == target_id and "webSocketDebuggerUrl" in t:
|
|
4863
|
+
try:
|
|
4864
|
+
import websockets
|
|
4865
|
+
async def _close():
|
|
4866
|
+
async with websockets.connect(
|
|
4867
|
+
t["webSocketDebuggerUrl"],
|
|
4868
|
+
max_size=10*1024*1024
|
|
4869
|
+
) as ws:
|
|
3942
4870
|
await ws.send(json.dumps({
|
|
3943
4871
|
"id": 1, "method": "Page.close", "params": {}
|
|
3944
4872
|
}))
|
|
@@ -4300,6 +5228,7 @@ async def cmd_new_tab(url='about:blank'):
|
|
|
4300
5228
|
data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
|
|
4301
5229
|
cdp_cache_invalidate()
|
|
4302
5230
|
if data:
|
|
5231
|
+
_mark_owned_tab(data.get("id"))
|
|
4303
5232
|
print(f'New tab opened: {data.get("url", url)}')
|
|
4304
5233
|
print(f' ID: {data.get("id", "?")}')
|
|
4305
5234
|
else:
|
|
@@ -4870,6 +5799,14 @@ def cmd_stealth(state=None):
|
|
|
4870
5799
|
os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
|
|
4871
5800
|
with open(STEALTH_CONFIG_FILE, 'w') as f:
|
|
4872
5801
|
json.dump({'stealth': enabled}, f)
|
|
5802
|
+
# Backward-compat bridge to the three-tier model: keep mode.json coherent
|
|
5803
|
+
# with the legacy toggle so `stealth on` -> 'undetected' (full patch) and
|
|
5804
|
+
# `stealth off` -> 'regular'. Without this, an existing mode.json would
|
|
5805
|
+
# silently override the toggle (get_mode_config reads mode.json first).
|
|
5806
|
+
try:
|
|
5807
|
+
set_mode_config('undetected' if enabled else 'regular')
|
|
5808
|
+
except Exception:
|
|
5809
|
+
pass
|
|
4873
5810
|
print(f'Stealth: {"on" if enabled else "off"}')
|
|
4874
5811
|
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
4875
5812
|
|
|
@@ -5128,18 +6065,25 @@ async def _assert_host(ws_url, expected_host):
|
|
|
5128
6065
|
|
|
5129
6066
|
|
|
5130
6067
|
def get_adaptive_config():
|
|
5131
|
-
"""Return
|
|
6068
|
+
"""Return adaptive config with all learned-state keys carried through.
|
|
6069
|
+
|
|
6070
|
+
Keys: enabled, stealth_hosts, entropy_hosts, host_tiers. The latter two
|
|
6071
|
+
MUST be preserved here because callers write the whole dict back via
|
|
6072
|
+
_atomic_write_json — dropping a key would silently erase learned state.
|
|
6073
|
+
"""
|
|
5132
6074
|
if not os.path.exists(ADAPTIVE_CONFIG_FILE):
|
|
5133
|
-
return {'enabled': False, 'stealth_hosts': []}
|
|
6075
|
+
return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
|
|
5134
6076
|
try:
|
|
5135
6077
|
with open(ADAPTIVE_CONFIG_FILE) as f:
|
|
5136
6078
|
data = json.load(f)
|
|
5137
6079
|
return {
|
|
5138
6080
|
'enabled': bool(data.get('enabled', False)),
|
|
5139
6081
|
'stealth_hosts': list(data.get('stealth_hosts', [])),
|
|
6082
|
+
'entropy_hosts': dict(data.get('entropy_hosts', {})),
|
|
6083
|
+
'host_tiers': dict(data.get('host_tiers', {})),
|
|
5140
6084
|
}
|
|
5141
6085
|
except (OSError, ValueError):
|
|
5142
|
-
return {'enabled': False, 'stealth_hosts': []}
|
|
6086
|
+
return {'enabled': False, 'stealth_hosts': [], 'entropy_hosts': {}, 'host_tiers': {}}
|
|
5143
6087
|
|
|
5144
6088
|
|
|
5145
6089
|
def _adaptive_remember_host(hostname):
|
|
@@ -5151,6 +6095,48 @@ def _adaptive_remember_host(hostname):
|
|
|
5151
6095
|
_atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
|
|
5152
6096
|
|
|
5153
6097
|
|
|
6098
|
+
def _escalate_tier(tier):
|
|
6099
|
+
"""Return the next-stronger tier. undetected is the ceiling."""
|
|
6100
|
+
try:
|
|
6101
|
+
i = MODE_TIERS.index(tier)
|
|
6102
|
+
except ValueError:
|
|
6103
|
+
i = 0
|
|
6104
|
+
return MODE_TIERS[min(i + 1, len(MODE_TIERS) - 1)]
|
|
6105
|
+
|
|
6106
|
+
|
|
6107
|
+
def _adaptive_remember_host_tier(hostname, tier):
|
|
6108
|
+
"""Record the tier a host needs in adaptive.json under 'host_tiers'.
|
|
6109
|
+
|
|
6110
|
+
Monotonic: only ever ratchets UP (regular -> stealth -> undetected),
|
|
6111
|
+
never down — mirrors the never-auto-demote policy of the stealth_hosts
|
|
6112
|
+
list so a single false-negative CAPTCHA detection can't downgrade a host.
|
|
6113
|
+
"""
|
|
6114
|
+
if tier not in MODE_TIERS:
|
|
6115
|
+
return
|
|
6116
|
+
cfg = get_adaptive_config()
|
|
6117
|
+
host_tiers = cfg.get('host_tiers', {})
|
|
6118
|
+
prev = host_tiers.get(hostname)
|
|
6119
|
+
if prev in MODE_TIERS and MODE_TIERS.index(prev) >= MODE_TIERS.index(tier):
|
|
6120
|
+
return # already at or above this tier, skip write
|
|
6121
|
+
host_tiers[hostname] = tier
|
|
6122
|
+
cfg['host_tiers'] = host_tiers
|
|
6123
|
+
_atomic_write_json(ADAPTIVE_CONFIG_FILE, cfg)
|
|
6124
|
+
|
|
6125
|
+
|
|
6126
|
+
def _adaptive_host_tier(url):
|
|
6127
|
+
"""Return the learned tier for a URL's host, or None if none/disabled."""
|
|
6128
|
+
cfg = get_adaptive_config()
|
|
6129
|
+
if not cfg.get('enabled'):
|
|
6130
|
+
return None
|
|
6131
|
+
try:
|
|
6132
|
+
from urllib.parse import urlparse
|
|
6133
|
+
host = (urlparse(url).hostname or '').lower()
|
|
6134
|
+
except Exception:
|
|
6135
|
+
return None
|
|
6136
|
+
tier = cfg.get('host_tiers', {}).get(host)
|
|
6137
|
+
return tier if tier in MODE_TIERS else None
|
|
6138
|
+
|
|
6139
|
+
|
|
5154
6140
|
def _adaptive_host_requires_stealth(url):
|
|
5155
6141
|
"""True if the URL's hostname is in the adaptive stealth list."""
|
|
5156
6142
|
cfg = get_adaptive_config()
|
|
@@ -5281,6 +6267,140 @@ async def _detect_captcha(ws_url):
|
|
|
5281
6267
|
return {"detected": False, "error": str(e)[:120]}
|
|
5282
6268
|
|
|
5283
6269
|
|
|
6270
|
+
# ─── Progressive-Resilience Escalation Ladder ───
|
|
6271
|
+
# Friction levels ordered low -> high. soft_captcha is detected by the existing
|
|
6272
|
+
# _detect_captcha (9 provider types); the other rungs come from
|
|
6273
|
+
# FRICTION_DETECT_JS. _detect_friction merges both and reports the highest.
|
|
6274
|
+
FRICTION_LEVELS = (
|
|
6275
|
+
'none', 'rate_limited', 'soft_captcha', 'login_wall', 'otp_sms', 'hard_block',
|
|
6276
|
+
)
|
|
6277
|
+
|
|
6278
|
+
|
|
6279
|
+
def _friction_backoff_enabled():
|
|
6280
|
+
"""Whether cmd_go should auto-backoff on rate_limited. Default on."""
|
|
6281
|
+
return os.environ.get('CDPILOT_FRICTION_BACKOFF', 'on').lower() not in ('off', '0', 'false', 'no')
|
|
6282
|
+
|
|
6283
|
+
|
|
6284
|
+
def _friction_max_retry():
|
|
6285
|
+
"""Max rate-limit backoff retries in cmd_go. Default 2, clamped 0..5."""
|
|
6286
|
+
try:
|
|
6287
|
+
n = int(os.environ.get('CDPILOT_FRICTION_MAX_RETRY', '2'))
|
|
6288
|
+
except (ValueError, TypeError):
|
|
6289
|
+
n = 2
|
|
6290
|
+
return max(0, min(n, 5))
|
|
6291
|
+
|
|
6292
|
+
|
|
6293
|
+
def _friction_backoff_seconds(attempt):
|
|
6294
|
+
"""Exponential backoff with jitter: 2^attempt seconds, capped at 60s.
|
|
6295
|
+
|
|
6296
|
+
attempt is 0-based (first retry -> 2s base). Adds up to 25% jitter so
|
|
6297
|
+
concurrent workers don't synchronize their retries.
|
|
6298
|
+
"""
|
|
6299
|
+
import random as _r
|
|
6300
|
+
base = min(2 ** (attempt + 1), 60)
|
|
6301
|
+
jitter = _r.uniform(0, base * 0.25)
|
|
6302
|
+
return round(min(base + jitter, 60), 2)
|
|
6303
|
+
|
|
6304
|
+
|
|
6305
|
+
def _friction_action(level):
|
|
6306
|
+
"""Map a friction level to the response policy cmd_go should apply.
|
|
6307
|
+
|
|
6308
|
+
ETHICS/SAFETY: login_wall, otp_sms and hard_block are NEVER bypassed
|
|
6309
|
+
autonomously — they hand off to the human. Only rate_limited is auto-handled
|
|
6310
|
+
(backoff + retry); soft_captcha defers to the existing captcha flow.
|
|
6311
|
+
|
|
6312
|
+
Returns a dict: {action, level, autonomous: bool, message}.
|
|
6313
|
+
"""
|
|
6314
|
+
if level == 'rate_limited':
|
|
6315
|
+
return {
|
|
6316
|
+
'action': 'backoff', 'level': level, 'autonomous': True,
|
|
6317
|
+
'message': 'Rate limited — exponential backoff then retry.',
|
|
6318
|
+
}
|
|
6319
|
+
if level == 'soft_captcha':
|
|
6320
|
+
return {
|
|
6321
|
+
'action': 'captcha', 'level': level, 'autonomous': True,
|
|
6322
|
+
'message': 'CAPTCHA — defer to detect/wait/solver flow.',
|
|
6323
|
+
}
|
|
6324
|
+
if level == 'login_wall':
|
|
6325
|
+
return {
|
|
6326
|
+
'action': 'human_login_required', 'level': level, 'autonomous': False,
|
|
6327
|
+
'message': '🔐 Login gerekli — kullanıcı girişi bekleniyor (otomatik giriş YOK).',
|
|
6328
|
+
}
|
|
6329
|
+
if level == 'otp_sms':
|
|
6330
|
+
return {
|
|
6331
|
+
'action': 'human_otp_required', 'level': level, 'autonomous': False,
|
|
6332
|
+
'message': '📱 SMS/OTP doğrulama gerekli — kullanıcı çözmeli (otomatik çözüm YOK).',
|
|
6333
|
+
}
|
|
6334
|
+
if level == 'hard_block':
|
|
6335
|
+
return {
|
|
6336
|
+
'action': 'hard_blocked', 'level': level, 'autonomous': False,
|
|
6337
|
+
'message': '🚫 Sert engelleme — geri çekil ve bekle.', 'backoff_suggested': True,
|
|
6338
|
+
}
|
|
6339
|
+
return {'action': 'proceed', 'level': 'none', 'autonomous': True, 'message': 'No friction.'}
|
|
6340
|
+
|
|
6341
|
+
|
|
6342
|
+
async def _detect_friction(ws_url):
|
|
6343
|
+
"""Detect the highest anti-bot friction rung on the active page.
|
|
6344
|
+
|
|
6345
|
+
Runs FRICTION_DETECT_JS (rate-limit / login-wall / OTP / hard-block) and
|
|
6346
|
+
merges in the existing _detect_captcha result (soft_captcha). Returns the
|
|
6347
|
+
HIGHEST rung present per FRICTION_LEVELS ordering.
|
|
6348
|
+
|
|
6349
|
+
Returns: {"level": str, "signals": [...], "detail": str, "captcha"?: {...}}
|
|
6350
|
+
Never raises — failures degrade to {"level": "none", ...}.
|
|
6351
|
+
"""
|
|
6352
|
+
friction = {"level": "none", "signals": [], "detail": ""}
|
|
6353
|
+
try:
|
|
6354
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
6355
|
+
"expression": FRICTION_DETECT_JS,
|
|
6356
|
+
"returnByValue": True,
|
|
6357
|
+
})], timeout=5)
|
|
6358
|
+
raw = r.get(1, {}).get("result", {}).get("value")
|
|
6359
|
+
if raw:
|
|
6360
|
+
try:
|
|
6361
|
+
parsed = json.loads(raw)
|
|
6362
|
+
if isinstance(parsed, dict) and parsed.get("level") in FRICTION_LEVELS:
|
|
6363
|
+
friction = parsed
|
|
6364
|
+
except (ValueError, TypeError):
|
|
6365
|
+
pass
|
|
6366
|
+
except Exception as e:
|
|
6367
|
+
friction = {"level": "none", "signals": [], "detail": "", "error": str(e)[:120]}
|
|
6368
|
+
|
|
6369
|
+
# Merge soft_captcha from the dedicated captcha detector.
|
|
6370
|
+
try:
|
|
6371
|
+
cap = await _detect_captcha(ws_url)
|
|
6372
|
+
except Exception:
|
|
6373
|
+
cap = {"detected": False}
|
|
6374
|
+
if cap.get("detected"):
|
|
6375
|
+
cap_level = 'soft_captcha'
|
|
6376
|
+
# Keep whichever rung is higher in the ladder.
|
|
6377
|
+
cur_idx = FRICTION_LEVELS.index(friction.get('level', 'none')) if friction.get('level') in FRICTION_LEVELS else 0
|
|
6378
|
+
cap_idx = FRICTION_LEVELS.index(cap_level)
|
|
6379
|
+
if cap_idx > cur_idx:
|
|
6380
|
+
sigs = list(friction.get('signals', []))
|
|
6381
|
+
sigs.append('captcha:' + (",".join(cap.get('types', [])) or 'unknown'))
|
|
6382
|
+
friction = {
|
|
6383
|
+
'level': cap_level, 'signals': sigs,
|
|
6384
|
+
'detail': 'CAPTCHA challenge present (' + (",".join(cap.get('types', [])) or 'unknown') + ').',
|
|
6385
|
+
}
|
|
6386
|
+
friction['captcha'] = {'types': cap.get('types', []), 'detected': True}
|
|
6387
|
+
return friction
|
|
6388
|
+
|
|
6389
|
+
|
|
6390
|
+
async def cmd_friction():
|
|
6391
|
+
"""One-shot friction-level diagnostic on the active page. Prints JSON.
|
|
6392
|
+
|
|
6393
|
+
Reports the highest escalation rung (none/rate_limited/soft_captcha/
|
|
6394
|
+
login_wall/otp_sms/hard_block) plus the recommended response policy.
|
|
6395
|
+
Read-only — never attempts to bypass anything.
|
|
6396
|
+
"""
|
|
6397
|
+
ws, _ = get_page_ws()
|
|
6398
|
+
info = await _detect_friction(ws)
|
|
6399
|
+
action = _friction_action(info.get('level', 'none'))
|
|
6400
|
+
info['recommended'] = action
|
|
6401
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
6402
|
+
|
|
6403
|
+
|
|
5284
6404
|
async def cmd_captcha_check():
|
|
5285
6405
|
"""One-shot CAPTCHA detection on the active page. Prints JSON.
|
|
5286
6406
|
|
|
@@ -6089,59 +7209,444 @@ async def cmd_captcha_dispatch(args: list) -> None:
|
|
|
6089
7209
|
sys.exit(1)
|
|
6090
7210
|
|
|
6091
7211
|
|
|
6092
|
-
# ───
|
|
7212
|
+
# ─── Amazon classic image CAPTCHA (local OCR + BYOK image solvers) ────────
|
|
7213
|
+
# amazon.com rate-limit page: "Enter the characters you see below". The image
|
|
7214
|
+
# is a 6-char distorted glyph string at a fixed URL pattern. The optional
|
|
7215
|
+
# `amazoncaptcha` PyPI lib (pure-Python + Pillow, MIT) OCRs it offline with
|
|
7216
|
+
# ~90% accuracy. It is NOT a mandatory dependency — imported lazily.
|
|
7217
|
+
# BYOK fallback: CapSolver / 2Captcha image-to-text endpoints (HTTP only).
|
|
6093
7218
|
|
|
6094
7219
|
|
|
6095
|
-
def
|
|
6096
|
-
"""
|
|
6097
|
-
|
|
7220
|
+
async def _detect_amazon_captcha(ws_url: str) -> dict:
|
|
7221
|
+
"""Return {'detected': bool, 'img_src': str, 'input_id': str} for Amazon classic CAPTCHA.
|
|
7222
|
+
|
|
7223
|
+
Probes the active page for the #captchacharacters input + captcha image.
|
|
7224
|
+
Never raises.
|
|
7225
|
+
"""
|
|
7226
|
+
info = await _detect_captcha(ws_url)
|
|
7227
|
+
if not info.get('detected'):
|
|
7228
|
+
return {'detected': False}
|
|
7229
|
+
for d in info.get('details', []):
|
|
7230
|
+
if d.get('type') == 'amazon-classic':
|
|
7231
|
+
return {
|
|
7232
|
+
'detected': True,
|
|
7233
|
+
'img_src': d.get('img_src', ''),
|
|
7234
|
+
'input_id': d.get('input_id', 'captchacharacters'),
|
|
7235
|
+
}
|
|
7236
|
+
return {'detected': False}
|
|
7237
|
+
|
|
7238
|
+
|
|
7239
|
+
async def _fetch_image_bytes(ws_url: str, img_src: str) -> bytes | None:
|
|
7240
|
+
"""Download the captcha image bytes. Uses urllib (image URLs are public CDN)."""
|
|
7241
|
+
if not img_src:
|
|
7242
|
+
return None
|
|
6098
7243
|
try:
|
|
6099
|
-
|
|
6100
|
-
["lsof", "-ti", f":{port}"],
|
|
6101
|
-
capture_output=True, text=True
|
|
6102
|
-
)
|
|
6103
|
-
pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
|
|
6104
|
-
if pids:
|
|
6105
|
-
for pid in pids:
|
|
6106
|
-
os.kill(int(pid), signal.SIGTERM)
|
|
6107
|
-
return True
|
|
6108
|
-
else:
|
|
6109
|
-
subprocess.run(
|
|
6110
|
-
["pkill", "-f", f"remote-debugging-port={port}"],
|
|
6111
|
-
capture_output=True, text=True
|
|
6112
|
-
)
|
|
6113
|
-
return True
|
|
7244
|
+
return await _captcha_urlopen_async(img_src, timeout=15)
|
|
6114
7245
|
except Exception:
|
|
6115
|
-
return
|
|
7246
|
+
return None
|
|
6116
7247
|
|
|
6117
7248
|
|
|
6118
|
-
|
|
7249
|
+
def _solve_amazon_local(img_bytes: bytes) -> str | None:
|
|
7250
|
+
"""OCR Amazon classic CAPTCHA with the OPTIONAL amazoncaptcha library.
|
|
6119
7251
|
|
|
6120
|
-
|
|
6121
|
-
|
|
7252
|
+
Returns the solved string, or None if the library is unavailable or fails.
|
|
7253
|
+
Raises ImportError-as-signal via returning None; caller prints install hint.
|
|
7254
|
+
"""
|
|
7255
|
+
try:
|
|
7256
|
+
from amazoncaptcha import AmazonCaptcha # optional dependency
|
|
7257
|
+
except ImportError:
|
|
7258
|
+
return None
|
|
7259
|
+
try:
|
|
7260
|
+
import io as _io
|
|
7261
|
+
cap = AmazonCaptcha(_io.BytesIO(img_bytes))
|
|
7262
|
+
solution = cap.solve()
|
|
7263
|
+
if solution and solution.lower() != 'not solved':
|
|
7264
|
+
return solution
|
|
7265
|
+
except Exception:
|
|
7266
|
+
return None
|
|
7267
|
+
return None
|
|
6122
7268
|
|
|
6123
7269
|
|
|
6124
|
-
def
|
|
6125
|
-
"""
|
|
6126
|
-
return 'sess_' + secrets.token_hex(4)
|
|
7270
|
+
async def _solve_image_byok(img_bytes: bytes) -> dict:
|
|
7271
|
+
"""Solve an image CAPTCHA via a BYOK provider (CapSolver / 2Captcha).
|
|
6127
7272
|
|
|
7273
|
+
Reads CAPSOLVER_API_KEY then TWOCAPTCHA_API_KEY from the environment.
|
|
7274
|
+
Only stdlib HTTP — no new dependency. Returns {'token': text} or {'error': ...}.
|
|
7275
|
+
"""
|
|
7276
|
+
import base64 as _b64
|
|
7277
|
+
b64 = _b64.b64encode(img_bytes).decode('ascii')
|
|
6128
7278
|
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
7279
|
+
capsolver_key = os.environ.get('CAPSOLVER_API_KEY')
|
|
7280
|
+
if capsolver_key:
|
|
7281
|
+
try:
|
|
7282
|
+
payload = json.dumps({
|
|
7283
|
+
'clientKey': capsolver_key,
|
|
7284
|
+
'task': {'type': 'ImageToTextTask', 'body': b64},
|
|
7285
|
+
}).encode()
|
|
7286
|
+
raw = await _captcha_urlopen_async(
|
|
7287
|
+
'https://api.capsolver.com/createTask', data=payload,
|
|
7288
|
+
headers={'Content-Type': 'application/json'}, timeout=60,
|
|
7289
|
+
)
|
|
7290
|
+
r = json.loads(raw)
|
|
7291
|
+
if r.get('errorId', 0) == 0:
|
|
7292
|
+
text = (r.get('solution') or {}).get('text', '')
|
|
7293
|
+
if text:
|
|
7294
|
+
return {'token': text, 'provider': 'capsolver'}
|
|
7295
|
+
return {'error': f"capsolver: {r.get('errorDescription', r)}"}
|
|
7296
|
+
except Exception as e:
|
|
7297
|
+
return {'error': f'capsolver: {e}'}
|
|
6133
7298
|
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
7299
|
+
twocaptcha_key = os.environ.get('TWOCAPTCHA_API_KEY')
|
|
7300
|
+
if twocaptcha_key:
|
|
7301
|
+
import urllib.parse as _uparse
|
|
7302
|
+
try:
|
|
7303
|
+
body = _uparse.urlencode({
|
|
7304
|
+
'key': twocaptcha_key, 'method': 'base64',
|
|
7305
|
+
'body': b64, 'json': '1',
|
|
7306
|
+
}).encode()
|
|
7307
|
+
raw = await _captcha_urlopen_async('https://2captcha.com/in.php', data=body, timeout=30)
|
|
7308
|
+
resp = json.loads(raw)
|
|
7309
|
+
if resp.get('status') != 1:
|
|
7310
|
+
return {'error': f"2captcha submit: {resp.get('request', resp)}"}
|
|
7311
|
+
task_id = resp['request']
|
|
7312
|
+
poll = f"https://2captcha.com/res.php?key={twocaptcha_key}&action=get&id={task_id}&json=1"
|
|
7313
|
+
deadline = time.time() + CAPTCHA_SOLVE_TIMEOUT
|
|
7314
|
+
while time.time() < deadline:
|
|
7315
|
+
await asyncio.sleep(5)
|
|
7316
|
+
r2 = json.loads(await _captcha_urlopen_async(poll, timeout=15))
|
|
7317
|
+
if r2.get('status') == 1:
|
|
7318
|
+
return {'token': r2['request'], 'provider': '2captcha'}
|
|
7319
|
+
if r2.get('request') != 'CAPCHA_NOT_READY':
|
|
7320
|
+
return {'error': f"2captcha: {r2.get('request')}"}
|
|
7321
|
+
return {'error': '2captcha timeout'}
|
|
7322
|
+
except Exception as e:
|
|
7323
|
+
return {'error': f'2captcha: {e}'}
|
|
6138
7324
|
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
7325
|
+
return {'error': 'no_byok_key'}
|
|
7326
|
+
|
|
7327
|
+
|
|
7328
|
+
async def _fill_amazon_solution(ws_url: str, input_id: str, solution: str) -> bool:
|
|
7329
|
+
"""Type the solution into the Amazon CAPTCHA input and submit. Returns True on success."""
|
|
7330
|
+
safe_id = json.dumps(input_id)
|
|
7331
|
+
safe_val = json.dumps(solution)
|
|
7332
|
+
js = f"""
|
|
7333
|
+
(function() {{
|
|
7334
|
+
try {{
|
|
7335
|
+
var el = document.getElementById({safe_id}) || document.querySelector('#captchacharacters');
|
|
7336
|
+
if (!el) return false;
|
|
7337
|
+
var setter = Object.getOwnPropertyDescriptor(
|
|
7338
|
+
window.HTMLInputElement.prototype, 'value').set;
|
|
7339
|
+
setter.call(el, {safe_val});
|
|
7340
|
+
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
7341
|
+
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
7342
|
+
var form = el.form || el.closest('form');
|
|
7343
|
+
if (form) {{ form.submit(); return true; }}
|
|
7344
|
+
var btn = document.querySelector('button[type=submit], input[type=submit]');
|
|
7345
|
+
if (btn) {{ btn.click(); return true; }}
|
|
7346
|
+
return true;
|
|
7347
|
+
}} catch(e) {{ return false; }}
|
|
7348
|
+
}})()
|
|
7349
|
+
"""
|
|
7350
|
+
try:
|
|
7351
|
+
r = await cdp_send(ws_url, [(1, 'Runtime.evaluate', {
|
|
7352
|
+
'expression': js, 'returnByValue': True,
|
|
7353
|
+
})], timeout=5)
|
|
7354
|
+
return bool(r.get(1, {}).get('result', {}).get('value', False))
|
|
7355
|
+
except Exception:
|
|
7356
|
+
return False
|
|
7357
|
+
|
|
7358
|
+
|
|
7359
|
+
async def cmd_press_hold(selector=None):
|
|
7360
|
+
"""Solve a PerimeterX/HUMAN "Press & Hold" challenge (standalone, opt-in).
|
|
7361
|
+
|
|
7362
|
+
Performs a humanized press->hold(with micro-tremor)->release gesture on the
|
|
7363
|
+
hold target. If no selector is given, auto-locates the px-captcha widget
|
|
7364
|
+
(#px-captcha / [class*="px-captcha"] / "press & hold" text). Prints JSON;
|
|
7365
|
+
exit 0 = solved, 1 = not solved / target not found.
|
|
7366
|
+
|
|
7367
|
+
Usage:
|
|
7368
|
+
cdpilot press-hold # auto-find the px-captcha target
|
|
7369
|
+
cdpilot press-hold "#px-captcha button"
|
|
7370
|
+
"""
|
|
7371
|
+
if not cdp_get("/json/version"):
|
|
7372
|
+
print(json.dumps({'solved': False, 'error': 'no_browser',
|
|
7373
|
+
'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
|
|
7374
|
+
sys.exit(1)
|
|
7375
|
+
ws, _ = get_page_ws()
|
|
7376
|
+
res = await _solve_press_and_hold(ws, target_sel=selector)
|
|
7377
|
+
print(json.dumps(res, ensure_ascii=False))
|
|
7378
|
+
sys.exit(0 if res.get('solved') else 1)
|
|
7379
|
+
|
|
7380
|
+
|
|
7381
|
+
async def cmd_captcha_solve(provider=None):
|
|
7382
|
+
"""Solve the CAPTCHA on the current page (opt-in).
|
|
7383
|
+
|
|
7384
|
+
Providers:
|
|
7385
|
+
amazon-local (default) — OCR Amazon classic image CAPTCHA with the optional
|
|
7386
|
+
`amazoncaptcha` lib (pip install amazoncaptcha).
|
|
7387
|
+
capsolver / 2captcha — BYOK image-to-text via CAPSOLVER_API_KEY /
|
|
7388
|
+
TWOCAPTCHA_API_KEY env vars (stdlib HTTP only).
|
|
7389
|
+
|
|
7390
|
+
Detects Amazon classic CAPTCHA (#captchacharacters + captcha image), solves,
|
|
7391
|
+
fills the input and submits. Prints JSON; exit 0 = solved, 3 = no captcha,
|
|
7392
|
+
1 = error / unsolvable.
|
|
7393
|
+
"""
|
|
7394
|
+
provider = (provider or 'amazon-local').lower()
|
|
7395
|
+
ws, _ = get_page_ws()
|
|
7396
|
+
|
|
7397
|
+
# PerimeterX / HUMAN "Press & Hold" is a behavioural (not token) challenge:
|
|
7398
|
+
# there is no provider to call, the only solution is a real humanized
|
|
7399
|
+
# press->hold->release gesture. Route it automatically whenever detected,
|
|
7400
|
+
# regardless of the requested provider, before the Amazon/BYOK path.
|
|
7401
|
+
pre = await _detect_captcha(ws)
|
|
7402
|
+
if 'perimeterx' in (pre.get('types') or []):
|
|
7403
|
+
res = await _solve_press_and_hold(ws)
|
|
7404
|
+
print(json.dumps(res, ensure_ascii=False))
|
|
7405
|
+
sys.exit(0 if res.get('solved') else 1)
|
|
7406
|
+
|
|
7407
|
+
amz = await _detect_amazon_captcha(ws)
|
|
7408
|
+
if not amz.get('detected'):
|
|
7409
|
+
# Not an Amazon classic captcha — report what (if anything) was found.
|
|
7410
|
+
info = await _detect_captcha(ws)
|
|
7411
|
+
print(json.dumps({
|
|
7412
|
+
'solved': False, 'status': 'no_amazon_captcha',
|
|
7413
|
+
'detected_types': info.get('types', []),
|
|
7414
|
+
}, ensure_ascii=False))
|
|
7415
|
+
sys.exit(3 if not info.get('detected') else 1)
|
|
7416
|
+
|
|
7417
|
+
img_src = amz.get('img_src', '')
|
|
7418
|
+
input_id = amz.get('input_id', 'captchacharacters')
|
|
7419
|
+
img_bytes = await _fetch_image_bytes(ws, img_src)
|
|
7420
|
+
if not img_bytes:
|
|
7421
|
+
print(json.dumps({'solved': False, 'error': 'image_fetch_failed', 'img_src': img_src}, ensure_ascii=False))
|
|
7422
|
+
sys.exit(1)
|
|
7423
|
+
|
|
7424
|
+
solution = None
|
|
7425
|
+
used_provider = None
|
|
7426
|
+
|
|
7427
|
+
if provider in ('amazon-local', 'amazon', 'local'):
|
|
7428
|
+
solution = _solve_amazon_local(img_bytes)
|
|
7429
|
+
used_provider = 'amazon-local'
|
|
7430
|
+
if solution is None:
|
|
7431
|
+
# Distinguish "lib missing" from "lib failed".
|
|
7432
|
+
try:
|
|
7433
|
+
import amazoncaptcha # noqa: F401
|
|
7434
|
+
print(json.dumps({
|
|
7435
|
+
'solved': False, 'status': 'ocr_failed',
|
|
7436
|
+
'provider': 'amazon-local',
|
|
7437
|
+
'hint': 'OCR returned no result; retry or use --provider capsolver',
|
|
7438
|
+
}, ensure_ascii=False))
|
|
7439
|
+
except ImportError:
|
|
7440
|
+
print(json.dumps({
|
|
7441
|
+
'solved': False, 'status': 'amazoncaptcha_not_installed',
|
|
7442
|
+
'hint': 'amazoncaptcha not installed. Run: pip install amazoncaptcha (optional)',
|
|
7443
|
+
}, ensure_ascii=False))
|
|
7444
|
+
sys.exit(1)
|
|
7445
|
+
elif provider in ('capsolver', '2captcha', 'twocaptcha'):
|
|
7446
|
+
res = await _solve_image_byok(img_bytes)
|
|
7447
|
+
if 'error' in res:
|
|
7448
|
+
if res['error'] == 'no_byok_key':
|
|
7449
|
+
print(json.dumps({
|
|
7450
|
+
'solved': False, 'status': 'no_byok_key',
|
|
7451
|
+
'hint': 'Set CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY in the environment',
|
|
7452
|
+
}, ensure_ascii=False))
|
|
7453
|
+
else:
|
|
7454
|
+
print(json.dumps({'solved': False, 'error': res['error']}, ensure_ascii=False))
|
|
7455
|
+
sys.exit(1)
|
|
7456
|
+
solution = res.get('token')
|
|
7457
|
+
used_provider = res.get('provider', provider)
|
|
7458
|
+
else:
|
|
7459
|
+
print(json.dumps({'solved': False, 'error': f'unknown_provider:{provider}'}, ensure_ascii=False))
|
|
7460
|
+
sys.exit(1)
|
|
7461
|
+
|
|
7462
|
+
if not solution:
|
|
7463
|
+
print(json.dumps({'solved': False, 'error': 'empty_solution', 'provider': used_provider}, ensure_ascii=False))
|
|
7464
|
+
sys.exit(1)
|
|
7465
|
+
|
|
7466
|
+
filled = await _fill_amazon_solution(ws, input_id, solution)
|
|
7467
|
+
print(json.dumps({
|
|
7468
|
+
'solved': True, 'solution': solution, 'provider': used_provider,
|
|
7469
|
+
'submitted': filled,
|
|
7470
|
+
}, ensure_ascii=False))
|
|
7471
|
+
sys.exit(0)
|
|
7472
|
+
|
|
7473
|
+
|
|
7474
|
+
# ─── Profile warm-up (reCAPTCHA v3 score aging) ──────────────────────────
|
|
7475
|
+
# New browser profiles get a -0.5 reCAPTCHA v3 trust penalty. Browsing a few
|
|
7476
|
+
# popular, safe sites ages the cookie jar / history so the profile reads as
|
|
7477
|
+
# "established", lifting the v3 score. Opt-in, uses the already-open session.
|
|
7478
|
+
|
|
7479
|
+
WARM_SAFE_SITES = [
|
|
7480
|
+
'https://en.wikipedia.org/wiki/Web_browser',
|
|
7481
|
+
'https://github.com/',
|
|
7482
|
+
'https://stackoverflow.com/',
|
|
7483
|
+
'https://news.ycombinator.com/',
|
|
7484
|
+
'https://www.bbc.com/news',
|
|
7485
|
+
'https://www.reddit.com/',
|
|
7486
|
+
'https://duckduckgo.com/',
|
|
7487
|
+
'https://www.wikipedia.org/',
|
|
7488
|
+
]
|
|
7489
|
+
|
|
7490
|
+
|
|
7491
|
+
async def cmd_profile_warm(minutes=None, sites=None):
|
|
7492
|
+
"""Warm up the current profile by browsing safe popular sites (opt-in).
|
|
7493
|
+
|
|
7494
|
+
Ages cookies/history to boost reCAPTCHA v3 score. Uses the already-open
|
|
7495
|
+
browser session (does not launch). Default ~2 minutes over a hardcoded
|
|
7496
|
+
safe site list with randomized dwell + light scrolling between visits.
|
|
7497
|
+
|
|
7498
|
+
Args:
|
|
7499
|
+
minutes: budget in minutes (default 2, max 30). String from CLI.
|
|
7500
|
+
sites: optional comma-separated override list of URLs.
|
|
7501
|
+
"""
|
|
7502
|
+
import random as _r
|
|
7503
|
+
try:
|
|
7504
|
+
budget_min = float(minutes) if minutes else 2.0
|
|
7505
|
+
except (ValueError, TypeError):
|
|
7506
|
+
budget_min = 2.0
|
|
7507
|
+
budget_min = max(0.5, min(budget_min, 30.0))
|
|
7508
|
+
|
|
7509
|
+
site_list = WARM_SAFE_SITES
|
|
7510
|
+
if sites:
|
|
7511
|
+
if isinstance(sites, str):
|
|
7512
|
+
site_list = [s.strip() for s in sites.split(',') if s.strip()]
|
|
7513
|
+
else:
|
|
7514
|
+
site_list = list(sites)
|
|
7515
|
+
if not site_list:
|
|
7516
|
+
site_list = WARM_SAFE_SITES
|
|
7517
|
+
|
|
7518
|
+
if not cdp_get("/json/version"):
|
|
7519
|
+
print(json.dumps({'error': 'no_browser', 'hint': 'Run: cdpilot launch'}, ensure_ascii=False))
|
|
7520
|
+
sys.exit(1)
|
|
7521
|
+
|
|
7522
|
+
ws, _ = get_page_ws()
|
|
7523
|
+
deadline = time.time() + budget_min * 60.0
|
|
7524
|
+
rnd = _r.Random()
|
|
7525
|
+
visited = 0
|
|
7526
|
+
total = len(site_list)
|
|
7527
|
+
|
|
7528
|
+
for idx, url in enumerate(site_list, 1):
|
|
7529
|
+
if time.time() >= deadline:
|
|
7530
|
+
break
|
|
7531
|
+
try:
|
|
7532
|
+
await navigate_collect(ws, url)
|
|
7533
|
+
visited += 1
|
|
7534
|
+
sys.stderr.write(f"🔥 Warming profile: visited {visited}/{total} sites... ({url})\n")
|
|
7535
|
+
sys.stderr.flush()
|
|
7536
|
+
except Exception as e:
|
|
7537
|
+
sys.stderr.write(f"🔥 Warm: skip {url} ({str(e)[:60]})\n")
|
|
7538
|
+
continue
|
|
7539
|
+
|
|
7540
|
+
# Light scroll to look engaged (reuses humanized scroll).
|
|
7541
|
+
try:
|
|
7542
|
+
await _humanize_scroll(ws, rnd.randint(400, 1200))
|
|
7543
|
+
except Exception:
|
|
7544
|
+
pass
|
|
7545
|
+
|
|
7546
|
+
# Random aging delay 5-15s, but never overrun the budget.
|
|
7547
|
+
remaining = deadline - time.time()
|
|
7548
|
+
if remaining <= 0:
|
|
7549
|
+
break
|
|
7550
|
+
await asyncio.sleep(min(rnd.uniform(5, 15), max(0.1, remaining)))
|
|
7551
|
+
|
|
7552
|
+
print(json.dumps({
|
|
7553
|
+
'warmed': True, 'visited': visited, 'sites': total,
|
|
7554
|
+
'budget_minutes': budget_min,
|
|
7555
|
+
}, ensure_ascii=False))
|
|
7556
|
+
|
|
7557
|
+
|
|
7558
|
+
async def cmd_profile_dispatch(args: list) -> None:
|
|
7559
|
+
"""Top-level dispatcher for 'cdpilot profile <subcommand> [args...]'.
|
|
7560
|
+
|
|
7561
|
+
Subcommands:
|
|
7562
|
+
warm [--minutes N] [--sites url1,url2] Warm up profile (reCAPTCHA v3 aging).
|
|
7563
|
+
"""
|
|
7564
|
+
if not args:
|
|
7565
|
+
print(
|
|
7566
|
+
"Usage: cdpilot profile <subcommand>\n"
|
|
7567
|
+
" warm [--minutes N] [--sites url1,url2,...] Age cookies/history to boost reCAPTCHA v3 score",
|
|
7568
|
+
file=sys.stderr,
|
|
7569
|
+
)
|
|
7570
|
+
sys.exit(1)
|
|
7571
|
+
|
|
7572
|
+
sub = args[0].lower()
|
|
7573
|
+
rest = args[1:]
|
|
7574
|
+
|
|
7575
|
+
def _get_flag(flag):
|
|
7576
|
+
for i, a in enumerate(rest):
|
|
7577
|
+
if a == flag and i + 1 < len(rest):
|
|
7578
|
+
return rest[i + 1]
|
|
7579
|
+
if a.startswith(flag + '='):
|
|
7580
|
+
return a.split('=', 1)[1]
|
|
7581
|
+
return None
|
|
7582
|
+
|
|
7583
|
+
if sub == 'warm':
|
|
7584
|
+
await cmd_profile_warm(
|
|
7585
|
+
minutes=_get_flag('--minutes') or _get_flag('-m'),
|
|
7586
|
+
sites=_get_flag('--sites'),
|
|
7587
|
+
)
|
|
7588
|
+
else:
|
|
7589
|
+
print(f"Unknown profile subcommand: {sub}", file=sys.stderr)
|
|
7590
|
+
sys.exit(1)
|
|
7591
|
+
|
|
7592
|
+
|
|
7593
|
+
# ─── End Captcha Solver Plugin ───────────────────────────────────────────
|
|
7594
|
+
|
|
7595
|
+
|
|
7596
|
+
def _stop_browser_on_port(port):
|
|
7597
|
+
"""Stop the browser process listening on the given port."""
|
|
7598
|
+
import signal
|
|
7599
|
+
try:
|
|
7600
|
+
# Bounded: on some hosts/sandboxes `lsof`/`pkill` can hang instead of
|
|
7601
|
+
# erroring (observed while testing the port-registry fix — a stuck
|
|
7602
|
+
# `lsof -ti :<port>` blocked the whole /v1/sessions DELETE request).
|
|
7603
|
+
# A timeout keeps this best-effort call from ever wedging the caller.
|
|
7604
|
+
result = subprocess.run(
|
|
7605
|
+
["lsof", "-ti", f":{port}"],
|
|
7606
|
+
capture_output=True, text=True, timeout=5
|
|
7607
|
+
)
|
|
7608
|
+
pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
|
|
7609
|
+
if pids:
|
|
7610
|
+
for pid in pids:
|
|
7611
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
7612
|
+
return True
|
|
7613
|
+
else:
|
|
7614
|
+
subprocess.run(
|
|
7615
|
+
["pkill", "-f", f"remote-debugging-port={port}"],
|
|
7616
|
+
capture_output=True, text=True, timeout=5
|
|
7617
|
+
)
|
|
7618
|
+
return True
|
|
7619
|
+
except Exception:
|
|
7620
|
+
return False
|
|
7621
|
+
|
|
7622
|
+
|
|
7623
|
+
# ─── Browserbase-Compatible Local API ───
|
|
7624
|
+
|
|
7625
|
+
DEFAULT_MAX_SESSIONS = int(os.environ.get('CDPILOT_MAX_SESSIONS', '10'))
|
|
7626
|
+
_api_session_store: dict = {} # session_id -> session_dict
|
|
7627
|
+
|
|
7628
|
+
|
|
7629
|
+
def _api_make_session_id() -> str:
|
|
7630
|
+
"""Generate sess_<8 hex chars> unique ID."""
|
|
7631
|
+
return 'sess_' + secrets.token_hex(4)
|
|
7632
|
+
|
|
7633
|
+
|
|
7634
|
+
def _api_create_session(opts: dict) -> dict:
|
|
7635
|
+
"""Launch browser for a new API session. Returns session dict."""
|
|
7636
|
+
if len(_api_session_store) >= DEFAULT_MAX_SESSIONS:
|
|
7637
|
+
raise ValueError("Maximum session limit reached")
|
|
7638
|
+
|
|
7639
|
+
session_id = _api_make_session_id()
|
|
7640
|
+
proj_id = f'api-{session_id}'
|
|
7641
|
+
port = _allocate_port(proj_id)
|
|
7642
|
+
created_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
7643
|
+
|
|
7644
|
+
sess = {
|
|
7645
|
+
'id': session_id,
|
|
7646
|
+
'createdAt': created_at,
|
|
7647
|
+
'projectId': proj_id,
|
|
7648
|
+
'status': 'RUNNING',
|
|
7649
|
+
'connectUrl': '',
|
|
6145
7650
|
'seleniumRemoteUrl': None,
|
|
6146
7651
|
'signingKey': None,
|
|
6147
7652
|
'port': port,
|
|
@@ -6189,7 +7694,11 @@ def _api_release_session(session_id: str) -> bool:
|
|
|
6189
7694
|
if not sess:
|
|
6190
7695
|
return False
|
|
6191
7696
|
port = sess.get('port')
|
|
6192
|
-
|
|
7697
|
+
# In TEST_MODE, _api_create_session never actually launched a browser on
|
|
7698
|
+
# `port` (see the sibling check there) — nothing is listening, so skip
|
|
7699
|
+
# the lsof/pkill round trip entirely instead of paying its cost for a
|
|
7700
|
+
# no-op every time.
|
|
7701
|
+
if port and os.environ.get('CDPILOT_API_TEST_MODE') != '1':
|
|
6193
7702
|
_stop_browser_on_port(port)
|
|
6194
7703
|
sess['status'] = 'STOPPED'
|
|
6195
7704
|
del _api_session_store[session_id]
|
|
@@ -6989,16 +8498,48 @@ async def cmd_smart_click(text):
|
|
|
6989
8498
|
cdpilot smart-click "Learn more"
|
|
6990
8499
|
"""
|
|
6991
8500
|
ws_url, _ = get_page_ws()
|
|
8501
|
+
# Snapshot page targets before the click so a new tab opened by the click
|
|
8502
|
+
# (target=_blank, window.open) can be marked cdpilot-owned afterwards.
|
|
8503
|
+
_pre_click_targets = {t.get("id") for t in (cdp_get("/json") or [])
|
|
8504
|
+
if t.get("type") == "page"}
|
|
8505
|
+
# Locale-aware lowercase so Turkish/German chars round-trip safely.
|
|
8506
|
+
# `toLocaleLowerCase()` (no arg) honors the browser's BCP-47 locale, which
|
|
8507
|
+
# matches what the user sees in the DOM. Python's str.lower() handles
|
|
8508
|
+
# ASCII fine for the *search* term that we marshal across; the in-page
|
|
8509
|
+
# comparison uses the same locale-aware function on both sides.
|
|
6992
8510
|
safe_text = json.dumps(text.lower())
|
|
6993
8511
|
js = f"""
|
|
6994
8512
|
(function() {{
|
|
6995
|
-
|
|
8513
|
+
// Walk into shadow roots so Salesforce Lightning / Polymer / any custom
|
|
8514
|
+
// element with attachShadow({{mode:'open'}}) becomes searchable.
|
|
8515
|
+
// Closed shadow roots are inaccessible by design — nothing we can do
|
|
8516
|
+
// about those without a DevTools-only API.
|
|
8517
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
8518
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
8519
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
8520
|
+
if (el.shadowRoot) {{
|
|
8521
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
8522
|
+
}}
|
|
8523
|
+
}});
|
|
8524
|
+
return found;
|
|
8525
|
+
}}
|
|
8526
|
+
|
|
8527
|
+
function lc(s) {{
|
|
8528
|
+
// Locale-aware lowercase — `toLowerCase()` mishandles Turkish (İ → i̇
|
|
8529
|
+
// combining sequence in some engines) and stays case-sensitive on
|
|
8530
|
+
// ß in older runtimes. `toLocaleLowerCase()` matches the user's
|
|
8531
|
+
// expected mental model: "İletişim" → "iletişim".
|
|
8532
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
8533
|
+
}}
|
|
8534
|
+
|
|
8535
|
+
var search = lc({safe_text}).trim();
|
|
6996
8536
|
var candidates = [];
|
|
8537
|
+
var disabledCount = 0;
|
|
6997
8538
|
|
|
6998
8539
|
// Score: exact > startsWith > includes > partial
|
|
6999
8540
|
function score(str) {{
|
|
7000
8541
|
if (!str) return 0;
|
|
7001
|
-
var s = str
|
|
8542
|
+
var s = lc(str).trim();
|
|
7002
8543
|
if (s === search) return 100;
|
|
7003
8544
|
if (s.startsWith(search)) return 80;
|
|
7004
8545
|
if (s.includes(search)) return 60;
|
|
@@ -7009,13 +8550,13 @@ async def cmd_smart_click(text):
|
|
|
7009
8550
|
return 0;
|
|
7010
8551
|
}}
|
|
7011
8552
|
|
|
7012
|
-
var els = document
|
|
8553
|
+
var els = deepQuerySelectorAll(document,
|
|
7013
8554
|
'a, button, input[type=submit], input[type=button], ' +
|
|
7014
8555
|
'[role=button], [role=link], [role=tab], [role=menuitem], ' +
|
|
7015
8556
|
'summary, label, [onclick], [tabindex]'
|
|
7016
8557
|
);
|
|
7017
8558
|
|
|
7018
|
-
|
|
8559
|
+
els.forEach(function(el) {{
|
|
7019
8560
|
var rect = el.getBoundingClientRect();
|
|
7020
8561
|
if (rect.width === 0 && rect.height === 0) return;
|
|
7021
8562
|
var style = window.getComputedStyle(el);
|
|
@@ -7034,10 +8575,23 @@ async def cmd_smart_click(text):
|
|
|
7034
8575
|
var bestMatch = '';
|
|
7035
8576
|
texts.forEach(function(t) {{
|
|
7036
8577
|
var s = score(t);
|
|
7037
|
-
if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
|
|
8578
|
+
if (s > bestScore) {{ bestScore = s; bestMatch = (t + '').trim().substring(0, 60); }}
|
|
7038
8579
|
}});
|
|
7039
8580
|
|
|
7040
8581
|
if (bestScore > 0) {{
|
|
8582
|
+
// Reject elements that look clickable but won't fire — `<button
|
|
8583
|
+
// disabled>`, `[aria-disabled=true]`, or a disabled ancestor
|
|
8584
|
+
// (fieldset[disabled] propagates to children). Without this the
|
|
8585
|
+
// command reports "Clicked" but nothing happens — silent fail.
|
|
8586
|
+
var isDisabled = (
|
|
8587
|
+
el.disabled === true ||
|
|
8588
|
+
el.getAttribute('aria-disabled') === 'true' ||
|
|
8589
|
+
(typeof el.closest === 'function' && el.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
|
|
8590
|
+
);
|
|
8591
|
+
if (isDisabled) {{
|
|
8592
|
+
disabledCount++;
|
|
8593
|
+
return;
|
|
8594
|
+
}}
|
|
7041
8595
|
candidates.push({{
|
|
7042
8596
|
el: el,
|
|
7043
8597
|
score: bestScore,
|
|
@@ -7049,7 +8603,13 @@ async def cmd_smart_click(text):
|
|
|
7049
8603
|
}}
|
|
7050
8604
|
}});
|
|
7051
8605
|
|
|
7052
|
-
if (candidates.length === 0)
|
|
8606
|
+
if (candidates.length === 0) {{
|
|
8607
|
+
return JSON.stringify({{
|
|
8608
|
+
found: false,
|
|
8609
|
+
allDisabled: disabledCount > 0,
|
|
8610
|
+
disabledCount: disabledCount
|
|
8611
|
+
}});
|
|
8612
|
+
}}
|
|
7053
8613
|
|
|
7054
8614
|
candidates.sort(function(a, b) {{ return b.score - a.score; }});
|
|
7055
8615
|
var best = candidates[0];
|
|
@@ -7078,10 +8638,26 @@ async def cmd_smart_click(text):
|
|
|
7078
8638
|
return
|
|
7079
8639
|
|
|
7080
8640
|
if not data.get("found"):
|
|
7081
|
-
|
|
8641
|
+
if data.get("allDisabled"):
|
|
8642
|
+
# Distinguish "no match" from "match exists but disabled" — the
|
|
8643
|
+
# latter is almost always a timing bug in the caller (form
|
|
8644
|
+
# validation hasn't unlocked the submit button yet).
|
|
8645
|
+
print(f'Error: no enabled element matches "{text}" '
|
|
8646
|
+
f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
|
|
8647
|
+
else:
|
|
8648
|
+
print(f'No element found matching: "{text}"', file=sys.stderr)
|
|
7082
8649
|
sys.exit(1)
|
|
7083
8650
|
|
|
7084
8651
|
await _vfx_ripple(ws_url, data["x"], data["y"])
|
|
8652
|
+
# A click can spawn a new tab (target=_blank). Diff the target list and mark
|
|
8653
|
+
# any newcomer as cdpilot-owned so `close` will clean it up later.
|
|
8654
|
+
try:
|
|
8655
|
+
cdp_cache_invalidate()
|
|
8656
|
+
for t in (cdp_get("/json", no_cache=True) or []):
|
|
8657
|
+
if t.get("type") == "page" and t.get("id") not in _pre_click_targets:
|
|
8658
|
+
_mark_owned_tab(t.get("id"))
|
|
8659
|
+
except Exception:
|
|
8660
|
+
pass
|
|
7085
8661
|
print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
|
|
7086
8662
|
if data.get("alternatives"):
|
|
7087
8663
|
print(f' Also found: {", ".join(data["alternatives"])}')
|
|
@@ -7287,7 +8863,9 @@ async def cmd_dismiss(repeat=None):
|
|
|
7287
8863
|
async def cmd_smart_fill(text, value):
|
|
7288
8864
|
"""Fill input by label/placeholder text — no CSS selector needed.
|
|
7289
8865
|
|
|
7290
|
-
Finds input by: associated label, placeholder, aria-label, name, id match
|
|
8866
|
+
Finds input by: associated label, placeholder, aria-label, name, id match,
|
|
8867
|
+
aria-labelledby, closest [aria-label], and nearby (4-prev) labels for
|
|
8868
|
+
floating-label designs (Material UI, Ant Design, Chakra).
|
|
7291
8869
|
|
|
7292
8870
|
Usage:
|
|
7293
8871
|
cdpilot smart-fill "Email" "test@example.com"
|
|
@@ -7299,44 +8877,97 @@ async def cmd_smart_fill(text, value):
|
|
|
7299
8877
|
safe_value = json.dumps(value)
|
|
7300
8878
|
js = f"""
|
|
7301
8879
|
(function() {{
|
|
7302
|
-
|
|
8880
|
+
// Shadow DOM traversal — same helper as smart-click. Custom-element
|
|
8881
|
+
// form controls (e.g. <sf-input> in Salesforce Lightning) only
|
|
8882
|
+
// expose their <input> via shadow root.
|
|
8883
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
8884
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
8885
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
8886
|
+
if (el.shadowRoot) {{
|
|
8887
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
8888
|
+
}}
|
|
8889
|
+
}});
|
|
8890
|
+
return found;
|
|
8891
|
+
}}
|
|
8892
|
+
|
|
8893
|
+
function lc(s) {{
|
|
8894
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
8895
|
+
}}
|
|
8896
|
+
|
|
8897
|
+
var search = lc({safe_text}).trim();
|
|
7303
8898
|
var value = {safe_value};
|
|
7304
8899
|
var candidates = [];
|
|
7305
8900
|
|
|
7306
8901
|
function score(str) {{
|
|
7307
8902
|
if (!str) return 0;
|
|
7308
|
-
var s = str
|
|
8903
|
+
var s = lc(str).trim();
|
|
7309
8904
|
if (s === search) return 100;
|
|
7310
8905
|
if (s.startsWith(search)) return 80;
|
|
7311
8906
|
if (s.includes(search)) return 60;
|
|
7312
8907
|
return 0;
|
|
7313
8908
|
}}
|
|
7314
8909
|
|
|
7315
|
-
var inputs = document
|
|
7316
|
-
|
|
8910
|
+
var inputs = deepQuerySelectorAll(document,
|
|
8911
|
+
'input, textarea, select, [contenteditable=true]');
|
|
8912
|
+
inputs.forEach(function(el) {{
|
|
7317
8913
|
var rect = el.getBoundingClientRect();
|
|
7318
8914
|
if (rect.width === 0 && rect.height === 0) return;
|
|
7319
8915
|
|
|
7320
8916
|
var scores = [];
|
|
7321
|
-
//
|
|
8917
|
+
// 1. placeholder
|
|
7322
8918
|
scores.push(score(el.getAttribute('placeholder') || ''));
|
|
7323
|
-
//
|
|
8919
|
+
// 2. aria-label
|
|
7324
8920
|
scores.push(score(el.getAttribute('aria-label') || ''));
|
|
7325
|
-
//
|
|
8921
|
+
// 3. name / id
|
|
7326
8922
|
scores.push(score(el.name || ''));
|
|
7327
8923
|
scores.push(score(el.id || ''));
|
|
7328
|
-
//
|
|
8924
|
+
// 4. label[for=id] (classic)
|
|
7329
8925
|
if (el.id) {{
|
|
7330
8926
|
var label = document.querySelector('label[for="' + el.id + '"]');
|
|
7331
8927
|
if (label) scores.push(score(label.textContent || ''));
|
|
7332
8928
|
}}
|
|
7333
|
-
//
|
|
8929
|
+
// 5. parent <label>
|
|
7334
8930
|
var parentLabel = el.closest('label');
|
|
7335
8931
|
if (parentLabel) scores.push(score(parentLabel.textContent || ''));
|
|
7336
|
-
//
|
|
8932
|
+
// 6. immediately-preceding sibling (legacy fallback)
|
|
7337
8933
|
var prev = el.previousElementSibling;
|
|
7338
8934
|
if (prev) scores.push(score(prev.textContent || ''));
|
|
7339
8935
|
|
|
8936
|
+
// 7. aria-labelledby — the input points at one or more IDs whose
|
|
8937
|
+
// textContent forms the accessible name. ARIA spec says space-
|
|
8938
|
+
// separated tokens, in order.
|
|
8939
|
+
var labelledby = el.getAttribute('aria-labelledby');
|
|
8940
|
+
if (labelledby) {{
|
|
8941
|
+
labelledby.split(/\\s+/).forEach(function(id) {{
|
|
8942
|
+
if (!id) return;
|
|
8943
|
+
var ref = document.getElementById(id);
|
|
8944
|
+
if (ref) scores.push(score(ref.textContent || ''));
|
|
8945
|
+
}});
|
|
8946
|
+
}}
|
|
8947
|
+
// 8. closest ancestor with [aria-label] — Material UI / Chakra wrap
|
|
8948
|
+
// the input in a labelled container instead of using <label>.
|
|
8949
|
+
if (typeof el.closest === 'function') {{
|
|
8950
|
+
var aria = el.closest('[aria-label]');
|
|
8951
|
+
if (aria && aria !== el) scores.push(score(aria.getAttribute('aria-label') || ''));
|
|
8952
|
+
}}
|
|
8953
|
+
// 9. nearby label — walk up to 4 previous siblings looking for a
|
|
8954
|
+
// <label> or label-like element. Floating-label designs render
|
|
8955
|
+
// the label as a separate sibling at the parent level.
|
|
8956
|
+
var node = el;
|
|
8957
|
+
for (var depth = 0; depth < 2 && node && node.parentElement; depth++) {{
|
|
8958
|
+
var sib = node.previousElementSibling;
|
|
8959
|
+
for (var i = 0; i < 4 && sib; i++) {{
|
|
8960
|
+
if (sib.tagName === 'LABEL' ||
|
|
8961
|
+
sib.matches && (sib.matches('label, [class*="label" i], [class*="Label"]'))) {{
|
|
8962
|
+
var t = (sib.textContent || '').trim();
|
|
8963
|
+
if (t && t.length < 80) scores.push(score(t));
|
|
8964
|
+
break;
|
|
8965
|
+
}}
|
|
8966
|
+
sib = sib.previousElementSibling;
|
|
8967
|
+
}}
|
|
8968
|
+
node = node.parentElement;
|
|
8969
|
+
}}
|
|
8970
|
+
|
|
7340
8971
|
var bestScore = Math.max.apply(null, scores);
|
|
7341
8972
|
if (bestScore > 0) {{
|
|
7342
8973
|
candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
|
|
@@ -7400,13 +9031,38 @@ async def cmd_smart_select(text, option_text):
|
|
|
7400
9031
|
safe_option = json.dumps(option_text.lower())
|
|
7401
9032
|
js = f"""
|
|
7402
9033
|
(function() {{
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
9034
|
+
// Shadow DOM + locale-aware comparison — same conventions as smart-click
|
|
9035
|
+
// / smart-fill so all three commands behave consistently on Lightning,
|
|
9036
|
+
// Polymer, and Turkish/German content.
|
|
9037
|
+
function deepQuerySelectorAll(root, selector) {{
|
|
9038
|
+
var found = Array.from(root.querySelectorAll(selector));
|
|
9039
|
+
Array.from(root.querySelectorAll('*')).forEach(function(el) {{
|
|
9040
|
+
if (el.shadowRoot) {{
|
|
9041
|
+
found = found.concat(deepQuerySelectorAll(el.shadowRoot, selector));
|
|
9042
|
+
}}
|
|
9043
|
+
}});
|
|
9044
|
+
return found;
|
|
9045
|
+
}}
|
|
9046
|
+
|
|
9047
|
+
function lc(s) {{
|
|
9048
|
+
return (s == null ? '' : (s + '')).toLocaleLowerCase();
|
|
9049
|
+
}}
|
|
9050
|
+
|
|
9051
|
+
var search = lc({safe_text}).trim();
|
|
9052
|
+
var optSearch = lc({safe_option}).trim();
|
|
9053
|
+
var selects = deepQuerySelectorAll(document, 'select');
|
|
7406
9054
|
var best = null;
|
|
7407
9055
|
var bestScore = 0;
|
|
7408
|
-
|
|
7409
|
-
|
|
9056
|
+
var disabledCount = 0;
|
|
9057
|
+
|
|
9058
|
+
selects.forEach(function(sel) {{
|
|
9059
|
+
// Skip disabled selects — same rationale as smart-click: silently
|
|
9060
|
+
// setting .value on a disabled <select> is a no-op and confusing.
|
|
9061
|
+
var isDisabled = (
|
|
9062
|
+
sel.disabled === true ||
|
|
9063
|
+
sel.getAttribute('aria-disabled') === 'true' ||
|
|
9064
|
+
(typeof sel.closest === 'function' && sel.closest('fieldset[disabled], [aria-disabled="true"]') !== null)
|
|
9065
|
+
);
|
|
7410
9066
|
var texts = [
|
|
7411
9067
|
sel.getAttribute('aria-label') || '',
|
|
7412
9068
|
sel.name || '', sel.id || ''
|
|
@@ -7418,21 +9074,30 @@ async def cmd_smart_select(text, option_text):
|
|
|
7418
9074
|
var parent = sel.closest('label');
|
|
7419
9075
|
if (parent) texts.push(parent.textContent || '');
|
|
7420
9076
|
|
|
9077
|
+
var matched = false;
|
|
7421
9078
|
texts.forEach(function(t) {{
|
|
7422
|
-
var s = t
|
|
9079
|
+
var s = lc(t).trim();
|
|
7423
9080
|
var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
|
|
7424
|
-
if (sc >
|
|
9081
|
+
if (sc > 0) matched = true;
|
|
9082
|
+
if (sc > bestScore && !isDisabled) {{ bestScore = sc; best = sel; }}
|
|
7425
9083
|
}});
|
|
9084
|
+
if (matched && isDisabled) disabledCount++;
|
|
7426
9085
|
}});
|
|
7427
9086
|
|
|
7428
|
-
if (!best)
|
|
9087
|
+
if (!best) {{
|
|
9088
|
+
return JSON.stringify({{
|
|
9089
|
+
found: false,
|
|
9090
|
+
allDisabled: disabledCount > 0,
|
|
9091
|
+
disabledCount: disabledCount
|
|
9092
|
+
}});
|
|
9093
|
+
}}
|
|
7429
9094
|
|
|
7430
9095
|
// Find matching option
|
|
7431
9096
|
var options = Array.from(best.options);
|
|
7432
9097
|
var match = options.find(function(o) {{
|
|
7433
|
-
return o.text
|
|
9098
|
+
return lc(o.text).trim() === optSearch;
|
|
7434
9099
|
}}) || options.find(function(o) {{
|
|
7435
|
-
return o.text
|
|
9100
|
+
return lc(o.text).includes(optSearch);
|
|
7436
9101
|
}});
|
|
7437
9102
|
|
|
7438
9103
|
if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
|
|
@@ -7451,7 +9116,11 @@ async def cmd_smart_select(text, option_text):
|
|
|
7451
9116
|
return
|
|
7452
9117
|
|
|
7453
9118
|
if not data.get("found"):
|
|
7454
|
-
|
|
9119
|
+
if data.get("allDisabled"):
|
|
9120
|
+
print(f'Error: no enabled select matches "{text}" '
|
|
9121
|
+
f'({data.get("disabledCount", 0)} disabled match(es) skipped)', file=sys.stderr)
|
|
9122
|
+
else:
|
|
9123
|
+
print(f'No select found matching: "{text}"', file=sys.stderr)
|
|
7455
9124
|
sys.exit(1)
|
|
7456
9125
|
if not data.get("optionFound"):
|
|
7457
9126
|
print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
|
|
@@ -9567,8 +11236,8 @@ class MCPServer:
|
|
|
9567
11236
|
"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
11237
|
{"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
11238
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
9570
|
-
{"name": "browser_close", "description": "
|
|
9571
|
-
"inputSchema": {"type": "object", "properties": {}}},
|
|
11239
|
+
{"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.",
|
|
11240
|
+
"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
11241
|
{"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
11242
|
"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
11243
|
{"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 +11246,11 @@ class MCPServer:
|
|
|
9577
11246
|
"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
11247
|
{"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
11248
|
"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"}}}},
|
|
11249
|
+
{"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.",
|
|
11250
|
+
"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"}}}},
|
|
11251
|
+
{"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."}}}},
|
|
11252
|
+
{"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.",
|
|
11253
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
9580
11254
|
{"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
11255
|
"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
11256
|
{"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 +11281,32 @@ class MCPServer:
|
|
|
9607
11281
|
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
|
|
9608
11282
|
{"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
11283
|
"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"]}},
|
|
11284
|
+
{"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.",
|
|
11285
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11286
|
+
"url": {"type": "string", "description": "URL or file:// path to load before recording. Pass '-' or omit to attach to the currently-loaded page."},
|
|
11287
|
+
"fps": {"type": "integer", "description": "Frames per second (1-30). Higher = more detail but bigger ring buffer.", "default": 10},
|
|
11288
|
+
"quality": {"type": "integer", "description": "JPEG quality 1-100.", "default": 70},
|
|
11289
|
+
"max_width": {"type": "integer", "description": "Maximum frame width in pixels (shrinks proportionally).", "default": 1280},
|
|
11290
|
+
"retention_s": {"type": "integer", "description": "Seconds to keep frames before eviction.", "default": 300},
|
|
11291
|
+
"disk_cap_mb": {"type": "integer", "description": "Maximum total frame size in MB before oldest-first eviction.", "default": 100},
|
|
11292
|
+
"seek": {"type": "string", "description": "Optional MM:SS or seconds to seek the page's first <video> element to."}
|
|
11293
|
+
}}},
|
|
11294
|
+
{"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.",
|
|
11295
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11296
|
+
"keep_frames": {"type": "boolean", "description": "Keep the captured frames on disk instead of deleting them.", "default": False}
|
|
11297
|
+
}}},
|
|
11298
|
+
{"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.",
|
|
11299
|
+
"inputSchema": {"type": "object", "properties": {
|
|
11300
|
+
"at": {"type": "string", "description": "Video time to center on, MM:SS or seconds (e.g. '1:23'). Measured from screencast start."},
|
|
11301
|
+
"window": {"type": "string", "description": "Width of the window around `at`, e.g. '5s', '500ms'.", "default": "5s"},
|
|
11302
|
+
"last": {"type": "string", "description": "Return frames from the last N seconds (e.g. '5s'). Relative to the newest frame on disk."},
|
|
11303
|
+
"since_last": {"type": "boolean", "description": "Return only frames newer than the previous watch_query call."},
|
|
11304
|
+
"max": {"type": "integer", "description": "Maximum number of frame paths to return (downsampled if window has more).", "default": 16}
|
|
11305
|
+
}}},
|
|
11306
|
+
{"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.",
|
|
11307
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
11308
|
+
{"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.",
|
|
11309
|
+
"inputSchema": {"type": "object", "properties": {"tier": {"type": "string", "enum": ["regular", "stealth", "undetected"], "description": "Tier to set. Omit to get the current tier."}}}},
|
|
9610
11310
|
]
|
|
9611
11311
|
|
|
9612
11312
|
def _handle_request(self, request):
|
|
@@ -9657,12 +11357,15 @@ class MCPServer:
|
|
|
9657
11357
|
"browser_a11y": lambda a: ["a11y"] + ([a["mode"]] if a.get("mode") and a["mode"] != "full" else []),
|
|
9658
11358
|
"browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
|
|
9659
11359
|
"browser_launch": lambda a: ["launch"],
|
|
9660
|
-
"browser_close": lambda a: ["close"],
|
|
11360
|
+
"browser_close": lambda a: ["close"] + (["--force"] if a.get("force") else []) + (["--keep"] if a.get("keep_browser") else []),
|
|
9661
11361
|
"browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
|
|
9662
11362
|
"browser_observe": lambda a: ["observe"],
|
|
9663
11363
|
"browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
|
|
9664
11364
|
"browser_dismiss": lambda a: ["dismiss"] + ([str(a["repeat"])] if a.get("repeat") else []),
|
|
9665
11365
|
"browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
|
|
11366
|
+
"browser_captcha_solve": lambda a: ["captcha-solve"] + ([f"--provider={a['provider']}"] if a.get("provider") else []),
|
|
11367
|
+
"browser_press_hold": lambda a: ["press-hold"] + ([a["selector"]] if a.get("selector") else []),
|
|
11368
|
+
"browser_friction": lambda a: ["friction"],
|
|
9666
11369
|
"browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
|
|
9667
11370
|
"browser_describe": lambda a: ["describe"],
|
|
9668
11371
|
"browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
|
|
@@ -9677,6 +11380,11 @@ class MCPServer:
|
|
|
9677
11380
|
"browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
|
|
9678
11381
|
"browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
|
|
9679
11382
|
"browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
|
|
11383
|
+
"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 []),
|
|
11384
|
+
"browser_watch_stop": lambda a: ["watch", "stop"] + (["--keep-frames"] if a.get("keep_frames") else []),
|
|
11385
|
+
"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 []),
|
|
11386
|
+
"browser_watch_status": lambda a: ["watch", "status"],
|
|
11387
|
+
"browser_mode": lambda a: ["mode"] + ([a["tier"]] if a.get("tier") else []),
|
|
9680
11388
|
}
|
|
9681
11389
|
if tool_name not in tool_map:
|
|
9682
11390
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
|
|
@@ -10409,6 +12117,817 @@ def _dispatch_blog_cmd(args):
|
|
|
10409
12117
|
# ─── End Blog Publish Namespace ───────────────────────────────────────────────
|
|
10410
12118
|
|
|
10411
12119
|
|
|
12120
|
+
# ─── cdpilot watch — continuous screencast for AI video understanding ─────────
|
|
12121
|
+
#
|
|
12122
|
+
# Why this exists: still screenshots taken at command-rate intervals (~1s)
|
|
12123
|
+
# cannot capture animation, mouse cursors, scroll dynamics, or short visual
|
|
12124
|
+
# events ("kedi sola mı sağa mı koştu?"). CDP's built-in Page.startScreencast
|
|
12125
|
+
# streams JPEG frames at 10-30fps directly from the renderer. We tee the
|
|
12126
|
+
# stream onto a disk ring buffer so AI orchestrators (Claude, etc.) can query
|
|
12127
|
+
# any time window after the fact and get real frames to feed into a
|
|
12128
|
+
# multimodal API. Two-process design: a long-lived daemon owns the WS and
|
|
12129
|
+
# writes frames; foreground `query/status/stop` commands only read disk.
|
|
12130
|
+
|
|
12131
|
+
WATCH_DEFAULT_FPS = 10
|
|
12132
|
+
WATCH_DEFAULT_QUALITY = 70
|
|
12133
|
+
WATCH_DEFAULT_MAX_WIDTH = 1280
|
|
12134
|
+
WATCH_DEFAULT_RETENTION_S = 300 # 5 minutes
|
|
12135
|
+
WATCH_DEFAULT_DISK_CAP_MB = 100
|
|
12136
|
+
WATCH_DAEMON_FLAG = '--_watch-daemon' # hidden — used only for re-entrant fork
|
|
12137
|
+
|
|
12138
|
+
|
|
12139
|
+
def _watch_dir():
|
|
12140
|
+
"""Return per-project watch directory (state + frames + index)."""
|
|
12141
|
+
pid = PROJECT_ID or _get_project_id()
|
|
12142
|
+
return os.path.join(CDPILOT_HOME, 'projects', pid, 'watch')
|
|
12143
|
+
|
|
12144
|
+
|
|
12145
|
+
def _watch_frames_dir():
|
|
12146
|
+
return os.path.join(_watch_dir(), 'frames')
|
|
12147
|
+
|
|
12148
|
+
|
|
12149
|
+
def _watch_state_path():
|
|
12150
|
+
return os.path.join(_watch_dir(), 'state.json')
|
|
12151
|
+
|
|
12152
|
+
|
|
12153
|
+
def _watch_index_path():
|
|
12154
|
+
return os.path.join(_watch_dir(), 'index.jsonl')
|
|
12155
|
+
|
|
12156
|
+
|
|
12157
|
+
def _watch_log_path():
|
|
12158
|
+
return os.path.join(_watch_dir(), 'daemon.log')
|
|
12159
|
+
|
|
12160
|
+
|
|
12161
|
+
def _watch_load_state():
|
|
12162
|
+
try:
|
|
12163
|
+
with open(_watch_state_path()) as f:
|
|
12164
|
+
return json.load(f)
|
|
12165
|
+
except (OSError, ValueError):
|
|
12166
|
+
return None
|
|
12167
|
+
|
|
12168
|
+
|
|
12169
|
+
def _watch_save_state(state):
|
|
12170
|
+
os.makedirs(_watch_dir(), exist_ok=True)
|
|
12171
|
+
tmp = _watch_state_path() + '.tmp'
|
|
12172
|
+
with open(tmp, 'w') as f:
|
|
12173
|
+
json.dump(state, f, indent=2)
|
|
12174
|
+
os.replace(tmp, _watch_state_path())
|
|
12175
|
+
|
|
12176
|
+
|
|
12177
|
+
def _watch_clear_state():
|
|
12178
|
+
for p in (_watch_state_path(), _watch_index_path()):
|
|
12179
|
+
try:
|
|
12180
|
+
os.remove(p)
|
|
12181
|
+
except OSError:
|
|
12182
|
+
pass
|
|
12183
|
+
|
|
12184
|
+
|
|
12185
|
+
def _watch_pid_alive(pid):
|
|
12186
|
+
# os.kill(pid, 0) terminates the process on Windows — see _pid_alive.
|
|
12187
|
+
return _pid_alive(pid)
|
|
12188
|
+
|
|
12189
|
+
|
|
12190
|
+
def _watch_parse_timecode(spec):
|
|
12191
|
+
"""Parse '1:23', '83', '1:23.5' into seconds (float). Returns None on failure."""
|
|
12192
|
+
if spec is None:
|
|
12193
|
+
return None
|
|
12194
|
+
s = str(spec).strip()
|
|
12195
|
+
if not s:
|
|
12196
|
+
return None
|
|
12197
|
+
try:
|
|
12198
|
+
if ':' in s:
|
|
12199
|
+
mm, ss = s.split(':', 1)
|
|
12200
|
+
return float(mm) * 60 + float(ss)
|
|
12201
|
+
return float(s)
|
|
12202
|
+
except ValueError:
|
|
12203
|
+
return None
|
|
12204
|
+
|
|
12205
|
+
|
|
12206
|
+
def _watch_parse_window(spec):
|
|
12207
|
+
"""Parse '5s', '2.5', '500ms' into seconds (float). Returns 5.0 on failure."""
|
|
12208
|
+
if spec is None:
|
|
12209
|
+
return 5.0
|
|
12210
|
+
s = str(spec).strip().lower()
|
|
12211
|
+
if not s:
|
|
12212
|
+
return 5.0
|
|
12213
|
+
try:
|
|
12214
|
+
if s.endswith('ms'):
|
|
12215
|
+
return float(s[:-2]) / 1000.0
|
|
12216
|
+
if s.endswith('s'):
|
|
12217
|
+
return float(s[:-1])
|
|
12218
|
+
return float(s)
|
|
12219
|
+
except ValueError:
|
|
12220
|
+
return 5.0
|
|
12221
|
+
|
|
12222
|
+
|
|
12223
|
+
def _watch_evict(retention_s, disk_cap_bytes):
|
|
12224
|
+
"""Drop frames older than retention OR over disk cap (oldest-first).
|
|
12225
|
+
|
|
12226
|
+
Returns (removed_count, bytes_freed).
|
|
12227
|
+
"""
|
|
12228
|
+
fdir = _watch_frames_dir()
|
|
12229
|
+
if not os.path.isdir(fdir):
|
|
12230
|
+
return 0, 0
|
|
12231
|
+
now_ms = int(time.time() * 1000)
|
|
12232
|
+
cutoff_ms = now_ms - int(retention_s * 1000)
|
|
12233
|
+
|
|
12234
|
+
files = []
|
|
12235
|
+
for name in os.listdir(fdir):
|
|
12236
|
+
if not name.endswith('.jpg'):
|
|
12237
|
+
continue
|
|
12238
|
+
try:
|
|
12239
|
+
ts_ms = int(name[:-4])
|
|
12240
|
+
except ValueError:
|
|
12241
|
+
continue
|
|
12242
|
+
path = os.path.join(fdir, name)
|
|
12243
|
+
try:
|
|
12244
|
+
size = os.path.getsize(path)
|
|
12245
|
+
except OSError:
|
|
12246
|
+
continue
|
|
12247
|
+
files.append((ts_ms, path, size))
|
|
12248
|
+
files.sort() # oldest first
|
|
12249
|
+
|
|
12250
|
+
removed = 0
|
|
12251
|
+
freed = 0
|
|
12252
|
+
# Age-based eviction
|
|
12253
|
+
keep = []
|
|
12254
|
+
for ts_ms, path, size in files:
|
|
12255
|
+
if ts_ms < cutoff_ms:
|
|
12256
|
+
try:
|
|
12257
|
+
os.remove(path)
|
|
12258
|
+
removed += 1
|
|
12259
|
+
freed += size
|
|
12260
|
+
except OSError:
|
|
12261
|
+
pass
|
|
12262
|
+
else:
|
|
12263
|
+
keep.append((ts_ms, path, size))
|
|
12264
|
+
|
|
12265
|
+
# Disk-cap eviction (drop oldest until under cap)
|
|
12266
|
+
total = sum(s for _, _, s in keep)
|
|
12267
|
+
while keep and total > disk_cap_bytes:
|
|
12268
|
+
ts_ms, path, size = keep.pop(0)
|
|
12269
|
+
try:
|
|
12270
|
+
os.remove(path)
|
|
12271
|
+
removed += 1
|
|
12272
|
+
freed += size
|
|
12273
|
+
total -= size
|
|
12274
|
+
except OSError:
|
|
12275
|
+
pass
|
|
12276
|
+
|
|
12277
|
+
return removed, freed
|
|
12278
|
+
|
|
12279
|
+
|
|
12280
|
+
def _watch_list_frames():
|
|
12281
|
+
"""Scan the ring buffer dir, return sorted list of (ts_ms, path)."""
|
|
12282
|
+
fdir = _watch_frames_dir()
|
|
12283
|
+
out = []
|
|
12284
|
+
if not os.path.isdir(fdir):
|
|
12285
|
+
return out
|
|
12286
|
+
for name in os.listdir(fdir):
|
|
12287
|
+
if not name.endswith('.jpg'):
|
|
12288
|
+
continue
|
|
12289
|
+
try:
|
|
12290
|
+
ts_ms = int(name[:-4])
|
|
12291
|
+
except ValueError:
|
|
12292
|
+
continue
|
|
12293
|
+
out.append((ts_ms, os.path.join(fdir, name)))
|
|
12294
|
+
out.sort()
|
|
12295
|
+
return out
|
|
12296
|
+
|
|
12297
|
+
|
|
12298
|
+
def _watch_frame_diff(a_path, b_path):
|
|
12299
|
+
"""Mean absolute pixel difference (0.0..1.0). Returns None if PIL missing.
|
|
12300
|
+
|
|
12301
|
+
Graceful degradation: if PIL/Pillow is not installed we return None and
|
|
12302
|
+
callers fall back to uniform frame picking.
|
|
12303
|
+
"""
|
|
12304
|
+
try:
|
|
12305
|
+
from PIL import Image, ImageChops, ImageStat # type: ignore
|
|
12306
|
+
except ImportError:
|
|
12307
|
+
return None
|
|
12308
|
+
try:
|
|
12309
|
+
with Image.open(a_path) as a_img, Image.open(b_path) as b_img:
|
|
12310
|
+
a_g = a_img.convert('L').resize((160, 90))
|
|
12311
|
+
b_g = b_img.convert('L').resize((160, 90))
|
|
12312
|
+
diff = ImageChops.difference(a_g, b_g)
|
|
12313
|
+
stat = ImageStat.Stat(diff)
|
|
12314
|
+
return float(stat.mean[0]) / 255.0
|
|
12315
|
+
except Exception:
|
|
12316
|
+
return None
|
|
12317
|
+
|
|
12318
|
+
|
|
12319
|
+
# ─── watch daemon (the actual screencast consumer) ───
|
|
12320
|
+
|
|
12321
|
+
async def _watch_daemon_run(url, fps, quality, max_width, retention_s,
|
|
12322
|
+
disk_cap_bytes, seek_s):
|
|
12323
|
+
"""Long-lived process: open WS, subscribe to screencastFrame, write JPEGs.
|
|
12324
|
+
|
|
12325
|
+
Runs until SIGTERM / parent kill. Each frame:
|
|
12326
|
+
1. base64-decode payload
|
|
12327
|
+
2. write to frames/<unix_ms>.jpg
|
|
12328
|
+
3. ACK with Page.screencastFrameAck (required, else stream stalls)
|
|
12329
|
+
4. periodically evict (every ~2s)
|
|
12330
|
+
"""
|
|
12331
|
+
import websockets
|
|
12332
|
+
|
|
12333
|
+
fdir = _watch_frames_dir()
|
|
12334
|
+
os.makedirs(fdir, exist_ok=True)
|
|
12335
|
+
|
|
12336
|
+
# Launch browser if needed
|
|
12337
|
+
if not cdp_get("/json/version"):
|
|
12338
|
+
cmd_launch()
|
|
12339
|
+
|
|
12340
|
+
ws_url, _ = get_page_ws()
|
|
12341
|
+
|
|
12342
|
+
# Navigate first (so the page exists before we start screencasting).
|
|
12343
|
+
# If url is empty we just attach to whatever is currently loaded.
|
|
12344
|
+
if url:
|
|
12345
|
+
try:
|
|
12346
|
+
await navigate_collect(ws_url, url, glow=False)
|
|
12347
|
+
except Exception as e:
|
|
12348
|
+
sys.stderr.write(f"watch: navigate failed: {e}\n")
|
|
12349
|
+
|
|
12350
|
+
# Try to coax a <video> element into autoplay + (optional) seek.
|
|
12351
|
+
# Best-effort — silent if the page has no video.
|
|
12352
|
+
autoplay_js = (
|
|
12353
|
+
"(()=>{const v=document.querySelector('video');"
|
|
12354
|
+
"if(!v) return {ok:false,reason:'no-video'};"
|
|
12355
|
+
"try{v.muted=true;v.playsInline=true;"
|
|
12356
|
+
+ (f"v.currentTime={float(seek_s)};" if seek_s is not None else "")
|
|
12357
|
+
+ "const p=v.play();if(p&&p.catch)p.catch(()=>{});"
|
|
12358
|
+
"return {ok:true,duration:v.duration||0,currentTime:v.currentTime};}"
|
|
12359
|
+
"catch(e){return {ok:false,reason:String(e)};}})()"
|
|
12360
|
+
)
|
|
12361
|
+
try:
|
|
12362
|
+
await cdp_send(ws_url, [(81, "Runtime.evaluate", {
|
|
12363
|
+
"expression": autoplay_js, "returnByValue": True,
|
|
12364
|
+
})])
|
|
12365
|
+
except Exception:
|
|
12366
|
+
pass
|
|
12367
|
+
|
|
12368
|
+
# everyNthFrame: CDP samples at ~60fps native; nth=6 → ~10fps.
|
|
12369
|
+
every_nth = max(1, int(round(60 / max(1, fps))))
|
|
12370
|
+
|
|
12371
|
+
# We bypass the pooled cdp_send for the screencast loop because we need
|
|
12372
|
+
# to consume *unsolicited* event frames (Page.screencastFrame) for the
|
|
12373
|
+
# entire daemon lifetime. The pool model assumes request/response.
|
|
12374
|
+
async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
|
|
12375
|
+
await ws.send(json.dumps({"id": 1, "method": "Page.enable", "params": {}}))
|
|
12376
|
+
await ws.send(json.dumps({
|
|
12377
|
+
"id": 2, "method": "Page.startScreencast",
|
|
12378
|
+
"params": {
|
|
12379
|
+
"format": "jpeg",
|
|
12380
|
+
"quality": int(quality),
|
|
12381
|
+
"maxWidth": int(max_width),
|
|
12382
|
+
"everyNthFrame": every_nth,
|
|
12383
|
+
},
|
|
12384
|
+
}))
|
|
12385
|
+
|
|
12386
|
+
last_evict = time.time()
|
|
12387
|
+
frame_count = 0
|
|
12388
|
+
# Update state file with confirmed start so `status` shows "running"
|
|
12389
|
+
st = _watch_load_state() or {}
|
|
12390
|
+
st['screencast_started_at_ms'] = int(time.time() * 1000)
|
|
12391
|
+
_watch_save_state(st)
|
|
12392
|
+
|
|
12393
|
+
while True:
|
|
12394
|
+
try:
|
|
12395
|
+
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
|
12396
|
+
except asyncio.TimeoutError:
|
|
12397
|
+
# Periodic eviction even if no frames arrive (e.g. page paused)
|
|
12398
|
+
if time.time() - last_evict > 2.0:
|
|
12399
|
+
_watch_evict(retention_s, disk_cap_bytes)
|
|
12400
|
+
last_evict = time.time()
|
|
12401
|
+
continue
|
|
12402
|
+
try:
|
|
12403
|
+
data = json.loads(resp)
|
|
12404
|
+
except ValueError:
|
|
12405
|
+
continue
|
|
12406
|
+
if data.get("method") != "Page.screencastFrame":
|
|
12407
|
+
continue
|
|
12408
|
+
params = data.get("params", {})
|
|
12409
|
+
session_id = params.get("sessionId")
|
|
12410
|
+
b64 = params.get("data", "")
|
|
12411
|
+
ts_ms = int(time.time() * 1000)
|
|
12412
|
+
|
|
12413
|
+
try:
|
|
12414
|
+
raw = base64.b64decode(b64)
|
|
12415
|
+
except Exception:
|
|
12416
|
+
raw = b""
|
|
12417
|
+
if raw:
|
|
12418
|
+
fpath = os.path.join(fdir, f"{ts_ms}.jpg")
|
|
12419
|
+
try:
|
|
12420
|
+
with open(fpath, 'wb') as f:
|
|
12421
|
+
f.write(raw)
|
|
12422
|
+
# Append to index (best-effort; missing entries are fine,
|
|
12423
|
+
# query falls back to scanning the dir directly)
|
|
12424
|
+
try:
|
|
12425
|
+
with open(_watch_index_path(), 'a') as idx:
|
|
12426
|
+
idx.write(json.dumps({
|
|
12427
|
+
"ts_ms": ts_ms,
|
|
12428
|
+
"filename": f"{ts_ms}.jpg",
|
|
12429
|
+
"size": len(raw),
|
|
12430
|
+
}) + "\n")
|
|
12431
|
+
except OSError:
|
|
12432
|
+
pass
|
|
12433
|
+
frame_count += 1
|
|
12434
|
+
except OSError as e:
|
|
12435
|
+
sys.stderr.write(f"watch: write failed: {e}\n")
|
|
12436
|
+
|
|
12437
|
+
# ACK is REQUIRED — CDP stops sending frames until acknowledged.
|
|
12438
|
+
try:
|
|
12439
|
+
await ws.send(json.dumps({
|
|
12440
|
+
"id": 1000 + (frame_count % 10000),
|
|
12441
|
+
"method": "Page.screencastFrameAck",
|
|
12442
|
+
"params": {"sessionId": session_id},
|
|
12443
|
+
}))
|
|
12444
|
+
except Exception:
|
|
12445
|
+
break
|
|
12446
|
+
|
|
12447
|
+
# Periodic eviction (every ~2s)
|
|
12448
|
+
if time.time() - last_evict > 2.0:
|
|
12449
|
+
_watch_evict(retention_s, disk_cap_bytes)
|
|
12450
|
+
last_evict = time.time()
|
|
12451
|
+
|
|
12452
|
+
|
|
12453
|
+
def _cmd_watch_daemon_entry():
|
|
12454
|
+
"""Internal: re-entrant entry point when called with --_watch-daemon flag.
|
|
12455
|
+
|
|
12456
|
+
Reads parameters from state file, runs the async screencast loop forever.
|
|
12457
|
+
Logs go to daemon.log so the foreground process can stay clean.
|
|
12458
|
+
"""
|
|
12459
|
+
state = _watch_load_state() or {}
|
|
12460
|
+
url = state.get('url') or ''
|
|
12461
|
+
fps = int(state.get('fps') or WATCH_DEFAULT_FPS)
|
|
12462
|
+
quality = int(state.get('quality') or WATCH_DEFAULT_QUALITY)
|
|
12463
|
+
max_width = int(state.get('max_width') or WATCH_DEFAULT_MAX_WIDTH)
|
|
12464
|
+
retention_s = float(state.get('retention_s') or WATCH_DEFAULT_RETENTION_S)
|
|
12465
|
+
disk_cap_bytes = int(state.get('disk_cap_bytes') or WATCH_DEFAULT_DISK_CAP_MB * 1024 * 1024)
|
|
12466
|
+
seek_s = state.get('seek_s')
|
|
12467
|
+
|
|
12468
|
+
state['pid'] = os.getpid()
|
|
12469
|
+
state['daemon_started_at_ms'] = int(time.time() * 1000)
|
|
12470
|
+
_watch_save_state(state)
|
|
12471
|
+
|
|
12472
|
+
try:
|
|
12473
|
+
asyncio.run(_watch_daemon_run(
|
|
12474
|
+
url, fps, quality, max_width, retention_s, disk_cap_bytes, seek_s
|
|
12475
|
+
))
|
|
12476
|
+
except KeyboardInterrupt:
|
|
12477
|
+
pass
|
|
12478
|
+
except Exception as e:
|
|
12479
|
+
try:
|
|
12480
|
+
with open(_watch_log_path(), 'a') as f:
|
|
12481
|
+
f.write(f"[{datetime.datetime.utcnow().isoformat()}] daemon crash: {e}\n")
|
|
12482
|
+
except OSError:
|
|
12483
|
+
pass
|
|
12484
|
+
|
|
12485
|
+
|
|
12486
|
+
def cmd_watch_start(*args):
|
|
12487
|
+
"""Start screencast capture in a background daemon.
|
|
12488
|
+
|
|
12489
|
+
cdpilot watch start <url|file://path|-> [--fps=10] [--quality=70]
|
|
12490
|
+
[--max-width=1280]
|
|
12491
|
+
[--retention=300]
|
|
12492
|
+
[--disk-cap=100]
|
|
12493
|
+
[--seek=1:23]
|
|
12494
|
+
|
|
12495
|
+
- URL may be omitted to attach to the currently-loaded page.
|
|
12496
|
+
- `-` as URL also means "attach, don't navigate".
|
|
12497
|
+
- Idempotent: if a daemon for this project is already running it is
|
|
12498
|
+
stopped first (we never want two screencasts on the same target).
|
|
12499
|
+
"""
|
|
12500
|
+
# Stop any prior daemon — two screencasts on one target would race.
|
|
12501
|
+
prev = _watch_load_state()
|
|
12502
|
+
if prev and _watch_pid_alive(prev.get('pid')):
|
|
12503
|
+
try:
|
|
12504
|
+
os.kill(int(prev['pid']), 15)
|
|
12505
|
+
except OSError:
|
|
12506
|
+
pass
|
|
12507
|
+
# brief wait so the WS releases
|
|
12508
|
+
for _ in range(20):
|
|
12509
|
+
if not _watch_pid_alive(prev.get('pid')):
|
|
12510
|
+
break
|
|
12511
|
+
time.sleep(0.05)
|
|
12512
|
+
|
|
12513
|
+
# Wipe ring buffer + index from any prior run (same project, different video)
|
|
12514
|
+
fdir = _watch_frames_dir()
|
|
12515
|
+
if os.path.isdir(fdir):
|
|
12516
|
+
for name in os.listdir(fdir):
|
|
12517
|
+
if name.endswith('.jpg'):
|
|
12518
|
+
try:
|
|
12519
|
+
os.remove(os.path.join(fdir, name))
|
|
12520
|
+
except OSError:
|
|
12521
|
+
pass
|
|
12522
|
+
try:
|
|
12523
|
+
os.remove(_watch_index_path())
|
|
12524
|
+
except OSError:
|
|
12525
|
+
pass
|
|
12526
|
+
|
|
12527
|
+
# Parse args
|
|
12528
|
+
url = ''
|
|
12529
|
+
fps = WATCH_DEFAULT_FPS
|
|
12530
|
+
quality = WATCH_DEFAULT_QUALITY
|
|
12531
|
+
max_width = WATCH_DEFAULT_MAX_WIDTH
|
|
12532
|
+
retention_s = WATCH_DEFAULT_RETENTION_S
|
|
12533
|
+
disk_cap_mb = WATCH_DEFAULT_DISK_CAP_MB
|
|
12534
|
+
seek_s = None
|
|
12535
|
+
|
|
12536
|
+
positional = []
|
|
12537
|
+
for a in args:
|
|
12538
|
+
if a.startswith('--fps='):
|
|
12539
|
+
try:
|
|
12540
|
+
fps = max(1, min(30, int(a.split('=', 1)[1])))
|
|
12541
|
+
except ValueError:
|
|
12542
|
+
pass
|
|
12543
|
+
elif a.startswith('--quality='):
|
|
12544
|
+
try:
|
|
12545
|
+
quality = max(1, min(100, int(a.split('=', 1)[1])))
|
|
12546
|
+
except ValueError:
|
|
12547
|
+
pass
|
|
12548
|
+
elif a.startswith('--max-width='):
|
|
12549
|
+
try:
|
|
12550
|
+
max_width = max(160, min(3840, int(a.split('=', 1)[1])))
|
|
12551
|
+
except ValueError:
|
|
12552
|
+
pass
|
|
12553
|
+
elif a.startswith('--retention='):
|
|
12554
|
+
try:
|
|
12555
|
+
retention_s = max(5, int(a.split('=', 1)[1]))
|
|
12556
|
+
except ValueError:
|
|
12557
|
+
pass
|
|
12558
|
+
elif a.startswith('--disk-cap='):
|
|
12559
|
+
try:
|
|
12560
|
+
disk_cap_mb = max(1, int(a.split('=', 1)[1]))
|
|
12561
|
+
except ValueError:
|
|
12562
|
+
pass
|
|
12563
|
+
elif a.startswith('--seek='):
|
|
12564
|
+
seek_s = _watch_parse_timecode(a.split('=', 1)[1])
|
|
12565
|
+
elif not a.startswith('--'):
|
|
12566
|
+
positional.append(a)
|
|
12567
|
+
|
|
12568
|
+
if positional:
|
|
12569
|
+
url = positional[0]
|
|
12570
|
+
if url == '-':
|
|
12571
|
+
url = ''
|
|
12572
|
+
|
|
12573
|
+
os.makedirs(_watch_frames_dir(), exist_ok=True)
|
|
12574
|
+
state = {
|
|
12575
|
+
'url': url,
|
|
12576
|
+
'fps': fps,
|
|
12577
|
+
'quality': quality,
|
|
12578
|
+
'max_width': max_width,
|
|
12579
|
+
'retention_s': retention_s,
|
|
12580
|
+
'disk_cap_bytes': disk_cap_mb * 1024 * 1024,
|
|
12581
|
+
'seek_s': seek_s,
|
|
12582
|
+
'started_at_ms': int(time.time() * 1000),
|
|
12583
|
+
'project_id': PROJECT_ID,
|
|
12584
|
+
'pid': None,
|
|
12585
|
+
}
|
|
12586
|
+
_watch_save_state(state)
|
|
12587
|
+
|
|
12588
|
+
# Fork the daemon. Use the same python + this script + the hidden flag.
|
|
12589
|
+
script = os.path.abspath(__file__)
|
|
12590
|
+
log_f = None
|
|
12591
|
+
try:
|
|
12592
|
+
log_f = open(_watch_log_path(), 'a')
|
|
12593
|
+
except OSError:
|
|
12594
|
+
log_f = subprocess.DEVNULL
|
|
12595
|
+
|
|
12596
|
+
env = os.environ.copy()
|
|
12597
|
+
env['CDPILOT_PROJECT_ID'] = PROJECT_ID or _get_project_id()
|
|
12598
|
+
# The daemon must use the same CDP port (same project's browser)
|
|
12599
|
+
env['CDP_PORT'] = str(CDP_PORT)
|
|
12600
|
+
|
|
12601
|
+
proc = subprocess.Popen(
|
|
12602
|
+
[sys.executable, script, WATCH_DAEMON_FLAG],
|
|
12603
|
+
stdout=log_f, stderr=log_f,
|
|
12604
|
+
env=env,
|
|
12605
|
+
start_new_session=True, # detach from this terminal's process group
|
|
12606
|
+
)
|
|
12607
|
+
state['pid'] = proc.pid
|
|
12608
|
+
_watch_save_state(state)
|
|
12609
|
+
|
|
12610
|
+
# Wait briefly for the daemon to confirm screencast start
|
|
12611
|
+
confirmed = False
|
|
12612
|
+
for _ in range(40): # up to ~2s
|
|
12613
|
+
time.sleep(0.05)
|
|
12614
|
+
st = _watch_load_state() or {}
|
|
12615
|
+
if st.get('screencast_started_at_ms'):
|
|
12616
|
+
confirmed = True
|
|
12617
|
+
break
|
|
12618
|
+
if not _watch_pid_alive(proc.pid):
|
|
12619
|
+
break
|
|
12620
|
+
|
|
12621
|
+
out = {
|
|
12622
|
+
'ok': confirmed or _watch_pid_alive(proc.pid),
|
|
12623
|
+
'pid': proc.pid,
|
|
12624
|
+
'url': url or '(attach)',
|
|
12625
|
+
'fps': fps,
|
|
12626
|
+
'quality': quality,
|
|
12627
|
+
'max_width': max_width,
|
|
12628
|
+
'retention_s': retention_s,
|
|
12629
|
+
'disk_cap_mb': disk_cap_mb,
|
|
12630
|
+
'frames_dir': _watch_frames_dir(),
|
|
12631
|
+
'state_path': _watch_state_path(),
|
|
12632
|
+
}
|
|
12633
|
+
print(json.dumps(out, indent=2))
|
|
12634
|
+
if not out['ok']:
|
|
12635
|
+
sys.exit(1)
|
|
12636
|
+
|
|
12637
|
+
|
|
12638
|
+
def cmd_watch_stop(*args):
|
|
12639
|
+
"""Stop the watch daemon and optionally cleanup frames."""
|
|
12640
|
+
keep_frames = '--keep-frames' in args
|
|
12641
|
+
state = _watch_load_state()
|
|
12642
|
+
if not state:
|
|
12643
|
+
print(json.dumps({'ok': True, 'message': 'no active watch session'}))
|
|
12644
|
+
return
|
|
12645
|
+
|
|
12646
|
+
pid = state.get('pid')
|
|
12647
|
+
killed = False
|
|
12648
|
+
if pid and _watch_pid_alive(pid):
|
|
12649
|
+
try:
|
|
12650
|
+
os.kill(int(pid), 15)
|
|
12651
|
+
killed = True
|
|
12652
|
+
except OSError:
|
|
12653
|
+
pass
|
|
12654
|
+
for _ in range(40):
|
|
12655
|
+
if not _watch_pid_alive(pid):
|
|
12656
|
+
break
|
|
12657
|
+
time.sleep(0.05)
|
|
12658
|
+
if _watch_pid_alive(pid):
|
|
12659
|
+
try:
|
|
12660
|
+
os.kill(int(pid), 9)
|
|
12661
|
+
except OSError:
|
|
12662
|
+
pass
|
|
12663
|
+
|
|
12664
|
+
removed = 0
|
|
12665
|
+
if not keep_frames:
|
|
12666
|
+
fdir = _watch_frames_dir()
|
|
12667
|
+
if os.path.isdir(fdir):
|
|
12668
|
+
for name in os.listdir(fdir):
|
|
12669
|
+
if name.endswith('.jpg'):
|
|
12670
|
+
try:
|
|
12671
|
+
os.remove(os.path.join(fdir, name))
|
|
12672
|
+
removed += 1
|
|
12673
|
+
except OSError:
|
|
12674
|
+
pass
|
|
12675
|
+
try:
|
|
12676
|
+
os.remove(_watch_index_path())
|
|
12677
|
+
except OSError:
|
|
12678
|
+
pass
|
|
12679
|
+
|
|
12680
|
+
_watch_clear_state()
|
|
12681
|
+
print(json.dumps({
|
|
12682
|
+
'ok': True,
|
|
12683
|
+
'killed': killed,
|
|
12684
|
+
'pid': pid,
|
|
12685
|
+
'frames_removed': removed,
|
|
12686
|
+
'kept_frames': keep_frames,
|
|
12687
|
+
}, indent=2))
|
|
12688
|
+
|
|
12689
|
+
|
|
12690
|
+
def cmd_watch_status(*args):
|
|
12691
|
+
"""Report daemon state, frame count, disk usage."""
|
|
12692
|
+
state = _watch_load_state()
|
|
12693
|
+
frames = _watch_list_frames()
|
|
12694
|
+
total_size = sum(os.path.getsize(p) for _, p in frames if os.path.exists(p)) if frames else 0
|
|
12695
|
+
|
|
12696
|
+
out = {
|
|
12697
|
+
'running': bool(state and _watch_pid_alive(state.get('pid'))),
|
|
12698
|
+
'frames': len(frames),
|
|
12699
|
+
'disk_bytes': total_size,
|
|
12700
|
+
'disk_mb': round(total_size / (1024 * 1024), 2),
|
|
12701
|
+
'oldest_ts_ms': frames[0][0] if frames else None,
|
|
12702
|
+
'newest_ts_ms': frames[-1][0] if frames else None,
|
|
12703
|
+
}
|
|
12704
|
+
if state:
|
|
12705
|
+
out['pid'] = state.get('pid')
|
|
12706
|
+
out['url'] = state.get('url') or '(attach)'
|
|
12707
|
+
out['fps'] = state.get('fps')
|
|
12708
|
+
out['started_at_ms'] = state.get('started_at_ms')
|
|
12709
|
+
out['screencast_started_at_ms'] = state.get('screencast_started_at_ms')
|
|
12710
|
+
out['retention_s'] = state.get('retention_s')
|
|
12711
|
+
out['disk_cap_mb'] = (state.get('disk_cap_bytes') or 0) // (1024 * 1024)
|
|
12712
|
+
print(json.dumps(out, indent=2))
|
|
12713
|
+
|
|
12714
|
+
|
|
12715
|
+
def _watch_last_query_path():
|
|
12716
|
+
return os.path.join(_watch_dir(), 'last-query.json')
|
|
12717
|
+
|
|
12718
|
+
|
|
12719
|
+
def cmd_watch_query(*args):
|
|
12720
|
+
"""Return frame paths inside a time window as JSON.
|
|
12721
|
+
|
|
12722
|
+
cdpilot watch query --at <mm:ss|sec> --window <Ns> [--max=16]
|
|
12723
|
+
cdpilot watch query --since-last [--max=16]
|
|
12724
|
+
cdpilot watch query --last <Ns> [--max=16] (relative to newest frame)
|
|
12725
|
+
|
|
12726
|
+
Time mapping: --at is interpreted as VIDEO time, measured from the moment
|
|
12727
|
+
the screencast started (state['screencast_started_at_ms']). This matches
|
|
12728
|
+
user intuition for "what happened at 0:35" because we begin capturing
|
|
12729
|
+
right after autoplay.
|
|
12730
|
+
"""
|
|
12731
|
+
state = _watch_load_state()
|
|
12732
|
+
if not state:
|
|
12733
|
+
print(json.dumps({'frames': [], 'count': 0, 'error': 'no watch session'}))
|
|
12734
|
+
return
|
|
12735
|
+
|
|
12736
|
+
max_frames = 16
|
|
12737
|
+
at_s = None
|
|
12738
|
+
window_s = 5.0
|
|
12739
|
+
since_last = False
|
|
12740
|
+
last_window = None
|
|
12741
|
+
|
|
12742
|
+
args_list = list(args)
|
|
12743
|
+
i = 0
|
|
12744
|
+
while i < len(args_list):
|
|
12745
|
+
a = args_list[i]
|
|
12746
|
+
if a == '--at' and i + 1 < len(args_list):
|
|
12747
|
+
at_s = _watch_parse_timecode(args_list[i + 1])
|
|
12748
|
+
i += 2
|
|
12749
|
+
elif a.startswith('--at='):
|
|
12750
|
+
at_s = _watch_parse_timecode(a.split('=', 1)[1])
|
|
12751
|
+
i += 1
|
|
12752
|
+
elif a == '--window' and i + 1 < len(args_list):
|
|
12753
|
+
window_s = _watch_parse_window(args_list[i + 1])
|
|
12754
|
+
i += 2
|
|
12755
|
+
elif a.startswith('--window='):
|
|
12756
|
+
window_s = _watch_parse_window(a.split('=', 1)[1])
|
|
12757
|
+
i += 1
|
|
12758
|
+
elif a == '--max' and i + 1 < len(args_list):
|
|
12759
|
+
try:
|
|
12760
|
+
max_frames = max(1, min(64, int(args_list[i + 1])))
|
|
12761
|
+
except ValueError:
|
|
12762
|
+
pass
|
|
12763
|
+
i += 2
|
|
12764
|
+
elif a.startswith('--max='):
|
|
12765
|
+
try:
|
|
12766
|
+
max_frames = max(1, min(64, int(a.split('=', 1)[1])))
|
|
12767
|
+
except ValueError:
|
|
12768
|
+
pass
|
|
12769
|
+
i += 1
|
|
12770
|
+
elif a == '--since-last':
|
|
12771
|
+
since_last = True
|
|
12772
|
+
i += 1
|
|
12773
|
+
elif a == '--last' and i + 1 < len(args_list):
|
|
12774
|
+
last_window = _watch_parse_window(args_list[i + 1])
|
|
12775
|
+
i += 2
|
|
12776
|
+
elif a.startswith('--last='):
|
|
12777
|
+
last_window = _watch_parse_window(a.split('=', 1)[1])
|
|
12778
|
+
i += 1
|
|
12779
|
+
else:
|
|
12780
|
+
i += 1
|
|
12781
|
+
|
|
12782
|
+
frames = _watch_list_frames()
|
|
12783
|
+
if not frames:
|
|
12784
|
+
print(json.dumps({'frames': [], 'count': 0, 'duration_s': 0}))
|
|
12785
|
+
return
|
|
12786
|
+
|
|
12787
|
+
sc_start_ms = state.get('screencast_started_at_ms') or state.get('started_at_ms') or frames[0][0]
|
|
12788
|
+
selected = []
|
|
12789
|
+
|
|
12790
|
+
if since_last:
|
|
12791
|
+
last_q = 0
|
|
12792
|
+
try:
|
|
12793
|
+
with open(_watch_last_query_path()) as f:
|
|
12794
|
+
last_q = int(json.load(f).get('ts_ms') or 0)
|
|
12795
|
+
except (OSError, ValueError):
|
|
12796
|
+
last_q = 0
|
|
12797
|
+
selected = [(ts, p) for ts, p in frames if ts > last_q]
|
|
12798
|
+
elif last_window is not None:
|
|
12799
|
+
newest_ts = frames[-1][0]
|
|
12800
|
+
lo_ms = newest_ts - int(last_window * 1000)
|
|
12801
|
+
selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
|
|
12802
|
+
elif at_s is not None:
|
|
12803
|
+
# Map video time to wall-clock ms
|
|
12804
|
+
center_ms = sc_start_ms + int(at_s * 1000)
|
|
12805
|
+
half_ms = int((window_s / 2.0) * 1000)
|
|
12806
|
+
lo_ms = center_ms - half_ms
|
|
12807
|
+
hi_ms = center_ms + half_ms
|
|
12808
|
+
selected = [(ts, p) for ts, p in frames if lo_ms <= ts <= hi_ms]
|
|
12809
|
+
else:
|
|
12810
|
+
# Default: the latest <window_s> seconds
|
|
12811
|
+
newest_ts = frames[-1][0]
|
|
12812
|
+
lo_ms = newest_ts - int(window_s * 1000)
|
|
12813
|
+
selected = [(ts, p) for ts, p in frames if ts >= lo_ms]
|
|
12814
|
+
|
|
12815
|
+
# Downsample to max_frames (uniform pick, keep first + last)
|
|
12816
|
+
if len(selected) > max_frames:
|
|
12817
|
+
# Try motion-aware picking when PIL is available
|
|
12818
|
+
motion_scores = []
|
|
12819
|
+
for j in range(1, len(selected)):
|
|
12820
|
+
d = _watch_frame_diff(selected[j - 1][1], selected[j][1])
|
|
12821
|
+
motion_scores.append(d if d is not None else 0.0)
|
|
12822
|
+
if motion_scores and any(s is not None for s in motion_scores):
|
|
12823
|
+
# Pick frames around the highest-motion peaks
|
|
12824
|
+
indices = sorted(range(len(motion_scores)),
|
|
12825
|
+
key=lambda k: motion_scores[k], reverse=True)
|
|
12826
|
+
pick_idx = sorted(set([0, len(selected) - 1] + indices[:max_frames - 2]))
|
|
12827
|
+
pick_idx = pick_idx[:max_frames]
|
|
12828
|
+
selected = [selected[k] for k in pick_idx]
|
|
12829
|
+
else:
|
|
12830
|
+
# Uniform downsample
|
|
12831
|
+
step = len(selected) / max_frames
|
|
12832
|
+
picks = [int(j * step) for j in range(max_frames)]
|
|
12833
|
+
picks[-1] = len(selected) - 1
|
|
12834
|
+
selected = [selected[k] for k in picks]
|
|
12835
|
+
|
|
12836
|
+
if selected:
|
|
12837
|
+
try:
|
|
12838
|
+
with open(_watch_last_query_path(), 'w') as f:
|
|
12839
|
+
json.dump({'ts_ms': selected[-1][0]}, f)
|
|
12840
|
+
except OSError:
|
|
12841
|
+
pass
|
|
12842
|
+
|
|
12843
|
+
duration_s = (selected[-1][0] - selected[0][0]) / 1000.0 if len(selected) >= 2 else 0.0
|
|
12844
|
+
out = {
|
|
12845
|
+
'frames': [p for _, p in selected],
|
|
12846
|
+
'timestamps_ms': [t for t, _ in selected],
|
|
12847
|
+
'count': len(selected),
|
|
12848
|
+
'duration_s': round(duration_s, 3),
|
|
12849
|
+
'window_s': window_s,
|
|
12850
|
+
'at_s': at_s,
|
|
12851
|
+
'screencast_started_at_ms': sc_start_ms,
|
|
12852
|
+
}
|
|
12853
|
+
print(json.dumps(out, indent=2))
|
|
12854
|
+
|
|
12855
|
+
|
|
12856
|
+
def cmd_watch_ask(*args):
|
|
12857
|
+
"""Tiny natural-language wrapper around `watch query`.
|
|
12858
|
+
|
|
12859
|
+
cdpilot watch ask "0:35'te kedi sola mı sağa mı koştu?"
|
|
12860
|
+
cdpilot watch ask "son 5 saniyede ne oldu"
|
|
12861
|
+
|
|
12862
|
+
We don't actually do LLM inference here — this command just parses a
|
|
12863
|
+
time window out of the question and emits the same JSON shape as
|
|
12864
|
+
`watch query` so the orchestrator (Claude) can pick up the frames and
|
|
12865
|
+
feed them to a multimodal API itself.
|
|
12866
|
+
"""
|
|
12867
|
+
if not args:
|
|
12868
|
+
print(json.dumps({'error': 'question required'}))
|
|
12869
|
+
sys.exit(1)
|
|
12870
|
+
question = ' '.join(args)
|
|
12871
|
+
q_lower = question.lower()
|
|
12872
|
+
|
|
12873
|
+
# Find mm:ss or m:ss anywhere in the string
|
|
12874
|
+
m = _re.search(r'(\d+):(\d{1,2})(?:\.\d+)?', question)
|
|
12875
|
+
at_s = None
|
|
12876
|
+
if m:
|
|
12877
|
+
at_s = float(m.group(1)) * 60 + float(m.group(2))
|
|
12878
|
+
|
|
12879
|
+
# "son N saniye(de)" / "last N seconds"
|
|
12880
|
+
last_match = _re.search(r'(?:son|last)\s+(\d+(?:\.\d+)?)\s*(?:saniye|sec|seconds?|s\b)', q_lower)
|
|
12881
|
+
last_window = float(last_match.group(1)) if last_match else None
|
|
12882
|
+
|
|
12883
|
+
forward = []
|
|
12884
|
+
if at_s is not None:
|
|
12885
|
+
forward = ['--at', f'{int(at_s // 60)}:{at_s % 60:.2f}'.rstrip('0').rstrip('.'),
|
|
12886
|
+
'--window', '5s']
|
|
12887
|
+
elif last_window is not None:
|
|
12888
|
+
forward = ['--last', f'{last_window}s']
|
|
12889
|
+
else:
|
|
12890
|
+
forward = ['--last', '5s']
|
|
12891
|
+
|
|
12892
|
+
forward += ['--max', '8']
|
|
12893
|
+
cmd_watch_query(*forward)
|
|
12894
|
+
|
|
12895
|
+
|
|
12896
|
+
def _dispatch_watch_cmd(args):
|
|
12897
|
+
"""Parse `cdpilot watch <sub> ...` and dispatch. Sync wrapper."""
|
|
12898
|
+
sub = args[0] if args else ''
|
|
12899
|
+
rest = args[1:]
|
|
12900
|
+
if not sub or sub in ('--help', '-h', 'help'):
|
|
12901
|
+
print("Usage: cdpilot watch <subcommand> [options]", file=sys.stderr)
|
|
12902
|
+
print("", file=sys.stderr)
|
|
12903
|
+
print("Subcommands:", file=sys.stderr)
|
|
12904
|
+
print(" start [url|-] [--fps=N] [--quality=N] [--max-width=N]", file=sys.stderr)
|
|
12905
|
+
print(" [--retention=SEC] [--disk-cap=MB] [--seek=MM:SS]", file=sys.stderr)
|
|
12906
|
+
print(" stop [--keep-frames]", file=sys.stderr)
|
|
12907
|
+
print(" query --at MM:SS --window 5s [--max=16]", file=sys.stderr)
|
|
12908
|
+
print(" query --since-last [--max=16]", file=sys.stderr)
|
|
12909
|
+
print(" query --last 5s [--max=16]", file=sys.stderr)
|
|
12910
|
+
print(" status", file=sys.stderr)
|
|
12911
|
+
print(" ask \"<natural-language question>\"", file=sys.stderr)
|
|
12912
|
+
sys.exit(0)
|
|
12913
|
+
if sub == 'start':
|
|
12914
|
+
cmd_watch_start(*rest)
|
|
12915
|
+
elif sub == 'stop':
|
|
12916
|
+
cmd_watch_stop(*rest)
|
|
12917
|
+
elif sub == 'query':
|
|
12918
|
+
cmd_watch_query(*rest)
|
|
12919
|
+
elif sub == 'status':
|
|
12920
|
+
cmd_watch_status(*rest)
|
|
12921
|
+
elif sub == 'ask':
|
|
12922
|
+
cmd_watch_ask(*rest)
|
|
12923
|
+
else:
|
|
12924
|
+
print(f"Unknown watch subcommand: {sub}. Run: cdpilot watch --help", file=sys.stderr)
|
|
12925
|
+
sys.exit(1)
|
|
12926
|
+
|
|
12927
|
+
|
|
12928
|
+
# ─── End cdpilot watch namespace ──────────────────────────────────────────────
|
|
12929
|
+
|
|
12930
|
+
|
|
10412
12931
|
# ─── CLI ───
|
|
10413
12932
|
|
|
10414
12933
|
if __name__ == "__main__":
|
|
@@ -10428,15 +12947,28 @@ if __name__ == "__main__":
|
|
|
10428
12947
|
print(__doc__)
|
|
10429
12948
|
sys.exit(0)
|
|
10430
12949
|
|
|
12950
|
+
# Hidden re-entrant entry for the watch daemon. `cdpilot watch start`
|
|
12951
|
+
# forks this process with the flag so the screencast consumer runs in
|
|
12952
|
+
# the background while the foreground process returns immediately.
|
|
12953
|
+
if cmd == WATCH_DAEMON_FLAG:
|
|
12954
|
+
_cmd_watch_daemon_entry()
|
|
12955
|
+
sys.exit(0)
|
|
12956
|
+
|
|
10431
12957
|
sync_cmds = {
|
|
10432
12958
|
'launch': cmd_launch,
|
|
10433
|
-
'tabs': cmd_tabs
|
|
12959
|
+
'tabs': lambda: cmd_tabs(
|
|
12960
|
+
reap='--reap' in args,
|
|
12961
|
+
max_tabs=next((int(a.split('=')[1]) for a in args
|
|
12962
|
+
if a.startswith('--max=') and a.split('=')[1].isdigit()), 1),
|
|
12963
|
+
include_real='--all' in args,
|
|
12964
|
+
dry_run='--dry-run' in args),
|
|
10434
12965
|
'extensions': cmd_extensions,
|
|
10435
12966
|
'stop': cmd_stop,
|
|
10436
12967
|
'version': cmd_version,
|
|
10437
12968
|
'proxy': lambda: cmd_proxy(*args),
|
|
10438
12969
|
'headless': lambda: cmd_headless(args[0] if args else None),
|
|
10439
12970
|
'stealth': lambda: cmd_stealth(args[0] if args else None),
|
|
12971
|
+
'mode': lambda: cmd_mode(args[0] if args else None),
|
|
10440
12972
|
'block': lambda: cmd_block(*args),
|
|
10441
12973
|
'context': lambda: cmd_context(*args),
|
|
10442
12974
|
'show': lambda: cmd_show(args[0] if args else None),
|
|
@@ -10458,6 +12990,7 @@ if __name__ == "__main__":
|
|
|
10458
12990
|
'test': lambda: cmd_test_dispatch(args),
|
|
10459
12991
|
'trace': lambda: cmd_trace_dispatch(args),
|
|
10460
12992
|
'blog': lambda: _dispatch_blog_cmd(args),
|
|
12993
|
+
'watch': lambda: _dispatch_watch_cmd(args),
|
|
10461
12994
|
}
|
|
10462
12995
|
|
|
10463
12996
|
if cmd == "serve":
|
|
@@ -10497,8 +13030,18 @@ if __name__ == "__main__":
|
|
|
10497
13030
|
cmd_switch_tab(args[0])
|
|
10498
13031
|
sys.exit(0)
|
|
10499
13032
|
|
|
13033
|
+
# `stop --smart` aliases the smart close (owned tabs + browser-if-empty).
|
|
13034
|
+
# Bare `stop` keeps its legacy full-kill behavior for backward compatibility.
|
|
13035
|
+
if cmd == "stop" and "--smart" in args:
|
|
13036
|
+
asyncio.run(cmd_close(
|
|
13037
|
+
force_browser="--force" in args,
|
|
13038
|
+
keep_browser=("--keep" in args or "--keep-browser" in args),
|
|
13039
|
+
))
|
|
13040
|
+
sys.exit(0)
|
|
13041
|
+
|
|
10500
13042
|
if cmd in sync_cmds:
|
|
10501
13043
|
sync_cmds[cmd]()
|
|
13044
|
+
_enforce_tab_budget()
|
|
10502
13045
|
sys.exit(0)
|
|
10503
13046
|
|
|
10504
13047
|
def require_args(n, usage):
|
|
@@ -10557,7 +13100,10 @@ if __name__ == "__main__":
|
|
|
10557
13100
|
"emulate": lambda: (require_args(1, "emulate <device>"), None)[1] if not args else cmd_emulate(args[0]),
|
|
10558
13101
|
"glow": lambda: cmd_glow(args[0] if args else "on"),
|
|
10559
13102
|
"debug": lambda: cmd_debug(args[0] if args else None),
|
|
10560
|
-
"close": cmd_close
|
|
13103
|
+
"close": lambda: cmd_close(
|
|
13104
|
+
force_browser="--force" in args,
|
|
13105
|
+
keep_browser=("--keep" in args or "--keep-browser" in args),
|
|
13106
|
+
),
|
|
10561
13107
|
'new-tab': lambda: cmd_new_tab(args[0] if args else 'about:blank'),
|
|
10562
13108
|
'close-tab': lambda: cmd_close_tab(args[0] if args else None),
|
|
10563
13109
|
'pdf': lambda: cmd_pdf(args[0] if args else None),
|
|
@@ -10614,6 +13160,15 @@ if __name__ == "__main__":
|
|
|
10614
13160
|
'captcha-check': cmd_captcha_check,
|
|
10615
13161
|
'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
|
|
10616
13162
|
'captcha': lambda: cmd_captcha_dispatch(args),
|
|
13163
|
+
'friction': cmd_friction,
|
|
13164
|
+
'captcha-solve': lambda: cmd_captcha_solve(
|
|
13165
|
+
next((a.split('=')[1] for a in args if a.startswith('--provider=')),
|
|
13166
|
+
args[args.index('--provider') + 1] if '--provider' in args and args.index('--provider') + 1 < len(args)
|
|
13167
|
+
else (next((a for a in args if not a.startswith('--')), None))),
|
|
13168
|
+
),
|
|
13169
|
+
'profile': lambda: cmd_profile_dispatch(args),
|
|
13170
|
+
'press-hold': lambda: cmd_press_hold(
|
|
13171
|
+
next((a for a in args if not a.startswith('--')), None)),
|
|
10617
13172
|
'agent': lambda: _dispatch_agent_cmd(args),
|
|
10618
13173
|
}
|
|
10619
13174
|
|
|
@@ -10621,7 +13176,8 @@ if __name__ == "__main__":
|
|
|
10621
13176
|
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
|
|
10622
13177
|
'dialog', 'download', 'throttle', 'permission', 'intercept',
|
|
10623
13178
|
'batch', 'screenshot-diff', 'run',
|
|
10624
|
-
'captcha-check', 'captcha-wait', 'captcha',
|
|
13179
|
+
'captcha-check', 'captcha-wait', 'captcha', 'friction',
|
|
13180
|
+
'profile',
|
|
10625
13181
|
'cookies'} # v0.6.1: cookies auto-config doesn't need browser
|
|
10626
13182
|
# Clean up idle sessions before running any command
|
|
10627
13183
|
_cleanup_idle_sessions()
|
|
@@ -10630,6 +13186,7 @@ if __name__ == "__main__":
|
|
|
10630
13186
|
if cmd in NO_CONTROL_CMDS:
|
|
10631
13187
|
asyncio.run(async_map[cmd]())
|
|
10632
13188
|
_update_session_timestamp()
|
|
13189
|
+
_enforce_tab_budget()
|
|
10633
13190
|
else:
|
|
10634
13191
|
async def _wrapped():
|
|
10635
13192
|
ws_url = None
|
|
@@ -10650,6 +13207,7 @@ if __name__ == "__main__":
|
|
|
10650
13207
|
await _control_end(ws_url)
|
|
10651
13208
|
asyncio.run(_wrapped())
|
|
10652
13209
|
_update_session_timestamp()
|
|
13210
|
+
_enforce_tab_budget()
|
|
10653
13211
|
else:
|
|
10654
13212
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
10655
13213
|
all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))
|