cdpilot 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/cdpilot.js +5 -0
- package/package.json +1 -1
- package/src/cdpilot.py +954 -5
package/README.md
CHANGED
|
@@ -301,6 +301,10 @@ The only browser MCP with built-in test assertions. Here's what we've shipped an
|
|
|
301
301
|
- [x] Pre-flight wizard (auto-installs dependencies on first run)
|
|
302
302
|
- [x] Persistent MCP glow (stays on during entire AI session, like Claude's orange glow)
|
|
303
303
|
- [x] DevExtension system (native JS injection without browser store)
|
|
304
|
+
- [x] **Smart commands** — `smart-click`, `smart-fill`, `smart-select` — interact by visible text, no CSS selectors needed, no LLM required
|
|
305
|
+
- [x] **Data extraction** (`extract`) — structured DOM data in text, JSON, or list format
|
|
306
|
+
- [x] **Page observation** (`observe`) — list all interactive elements with available actions
|
|
307
|
+
- [x] **Script runner** (`run`) — execute `.cdp` script files with pass/fail reporting
|
|
304
308
|
|
|
305
309
|
### Coming Soon
|
|
306
310
|
|
package/bin/cdpilot.js
CHANGED
|
@@ -324,6 +324,11 @@ function showHelp() {
|
|
|
324
324
|
new-tab [url] Open new tab
|
|
325
325
|
close-tab [id] Close tab
|
|
326
326
|
|
|
327
|
+
STEALTH & CAPTCHA
|
|
328
|
+
stealth [on|off] Toggle anti-fingerprint patches (opt-in)
|
|
329
|
+
captcha-check Detect CAPTCHA on active page (JSON output)
|
|
330
|
+
captcha-wait [s] Pause until user solves CAPTCHA (default 300s)
|
|
331
|
+
|
|
327
332
|
PROJECTS
|
|
328
333
|
projects List all project browser instances
|
|
329
334
|
project-stop <id> Stop a specific project's browser
|
package/package.json
CHANGED
package/src/cdpilot.py
CHANGED
|
@@ -14,7 +14,7 @@ Environment:
|
|
|
14
14
|
CDPILOT_PROFILE Isolated browser profile directory
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.4.0"
|
|
18
18
|
|
|
19
19
|
import asyncio
|
|
20
20
|
import json
|
|
@@ -221,8 +221,250 @@ window.__cdpilot_waitFor = function(selector, timeout) {
|
|
|
221
221
|
});
|
|
222
222
|
};
|
|
223
223
|
"""
|
|
224
|
+
# ─── Stealth & CAPTCHA Detection ──────────────────────────────────────────────
|
|
225
|
+
# Zero-dependency anti-fingerprinting layer. Patches common automation tells
|
|
226
|
+
# (navigator.webdriver, missing chrome.runtime, plugin list, WebGL vendor).
|
|
227
|
+
# Runs via Page.addScriptToEvaluateOnNewDocument BEFORE any page script.
|
|
228
|
+
# Notes:
|
|
229
|
+
# - Does NOT weaken web security (CSP, CORS, SOP intact).
|
|
230
|
+
# - Idempotent: guarded by window.__cdpilot_stealth flag.
|
|
231
|
+
# - Fails silently per-patch; one failure cannot break the page.
|
|
232
|
+
# - Opt-in via `cdpilot stealth on`. Default OFF for backward compatibility.
|
|
233
|
+
STEALTH_JS = r"""
|
|
234
|
+
(function() {
|
|
235
|
+
'use strict';
|
|
236
|
+
if (window.__cdpilot_stealth) return;
|
|
237
|
+
try { Object.defineProperty(window, '__cdpilot_stealth', {value: true, writable: false, configurable: false}); } catch(e) {}
|
|
238
|
+
|
|
239
|
+
// 1) navigator.webdriver — only patch if it's actually `true`.
|
|
240
|
+
// Vanilla Chrome (no --enable-automation) already returns `false` and the
|
|
241
|
+
// property exists on Navigator.prototype. Replacing the getter when it's
|
|
242
|
+
// already safe creates a worse fingerprint (returning `undefined` instead
|
|
243
|
+
// of `false` is itself a tell). Only mask when CDP/automation flagged it.
|
|
244
|
+
try {
|
|
245
|
+
var wdValue;
|
|
246
|
+
try { wdValue = navigator.webdriver; } catch (_) {}
|
|
247
|
+
if (wdValue === true) {
|
|
248
|
+
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
|
249
|
+
get: function() { return false; },
|
|
250
|
+
configurable: true
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {}
|
|
254
|
+
|
|
255
|
+
// 2) window.chrome — real Chrome exposes chrome.runtime even without extensions
|
|
256
|
+
try {
|
|
257
|
+
if (!window.chrome) {
|
|
258
|
+
window.chrome = {};
|
|
259
|
+
}
|
|
260
|
+
if (!window.chrome.runtime) {
|
|
261
|
+
window.chrome.runtime = {
|
|
262
|
+
OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {},
|
|
263
|
+
PlatformNackArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (typeof window.chrome.app === 'undefined') {
|
|
267
|
+
window.chrome.app = { isInstalled: false, InstallState: {}, RunningState: {} };
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {}
|
|
270
|
+
|
|
271
|
+
// 3) Permissions.query — reconcile with Notification.permission (classic tell)
|
|
272
|
+
try {
|
|
273
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
274
|
+
var origQuery = navigator.permissions.query.bind(navigator.permissions);
|
|
275
|
+
navigator.permissions.query = function(p) {
|
|
276
|
+
if (p && p.name === 'notifications') {
|
|
277
|
+
return Promise.resolve({ state: Notification.permission, onchange: null });
|
|
278
|
+
}
|
|
279
|
+
return origQuery(p);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {}
|
|
283
|
+
|
|
284
|
+
// 4) navigator.plugins — empty array is a headless tell. Provide standard PDF set.
|
|
285
|
+
// Critical: must be a real PluginArray instance, not vanilla Array, or
|
|
286
|
+
// detection scripts catch it via `navigator.plugins instanceof PluginArray`.
|
|
287
|
+
try {
|
|
288
|
+
var PluginArrayProto = (window.PluginArray && window.PluginArray.prototype) || null;
|
|
289
|
+
var PluginProto = (window.Plugin && window.Plugin.prototype) || null;
|
|
290
|
+
var MimeTypeProto = (window.MimeType && window.MimeType.prototype) || null;
|
|
291
|
+
|
|
292
|
+
var makeMime = function(type, suffixes, plugin) {
|
|
293
|
+
var m = Object.create(MimeTypeProto || Object.prototype);
|
|
294
|
+
Object.defineProperties(m, {
|
|
295
|
+
type: { value: type, enumerable: true },
|
|
296
|
+
suffixes: { value: suffixes, enumerable: true },
|
|
297
|
+
description: { value: '', enumerable: true },
|
|
298
|
+
enabledPlugin: { value: plugin, enumerable: true }
|
|
299
|
+
});
|
|
300
|
+
return m;
|
|
301
|
+
};
|
|
302
|
+
var makePlugin = function(name, filename) {
|
|
303
|
+
var p = Object.create(PluginProto || Object.prototype);
|
|
304
|
+
var mime = makeMime('application/pdf', 'pdf', p);
|
|
305
|
+
Object.defineProperties(p, {
|
|
306
|
+
name: { value: name, enumerable: true },
|
|
307
|
+
filename: { value: filename, enumerable: true },
|
|
308
|
+
description: { value: 'Portable Document Format', enumerable: true },
|
|
309
|
+
length: { value: 1, enumerable: true },
|
|
310
|
+
'0': { value: mime, enumerable: true }
|
|
311
|
+
});
|
|
312
|
+
p.item = function(i) { return i === 0 ? mime : null; };
|
|
313
|
+
p.namedItem = function(n) { return n === mime.type ? mime : null; };
|
|
314
|
+
return p;
|
|
315
|
+
};
|
|
316
|
+
var pluginNames = [
|
|
317
|
+
['PDF Viewer', 'internal-pdf-viewer'],
|
|
318
|
+
['Chrome PDF Viewer', 'internal-pdf-viewer'],
|
|
319
|
+
['Chromium PDF Viewer', 'internal-pdf-viewer'],
|
|
320
|
+
['Microsoft Edge PDF Viewer', 'internal-pdf-viewer'],
|
|
321
|
+
['WebKit built-in PDF', 'internal-pdf-viewer'],
|
|
322
|
+
];
|
|
323
|
+
var plugins = Object.create(PluginArrayProto || Array.prototype);
|
|
324
|
+
for (var i = 0; i < pluginNames.length; i++) {
|
|
325
|
+
var p = makePlugin(pluginNames[i][0], pluginNames[i][1]);
|
|
326
|
+
Object.defineProperty(plugins, i, { value: p, enumerable: true });
|
|
327
|
+
Object.defineProperty(plugins, p.name, { value: p });
|
|
328
|
+
}
|
|
329
|
+
Object.defineProperty(plugins, 'length', { value: pluginNames.length });
|
|
330
|
+
plugins.item = function(i) { return plugins[i] || null; };
|
|
331
|
+
plugins.namedItem = function(n) {
|
|
332
|
+
for (var k = 0; k < plugins.length; k++) if (plugins[k].name === n) return plugins[k];
|
|
333
|
+
return null;
|
|
334
|
+
};
|
|
335
|
+
plugins.refresh = function() {};
|
|
336
|
+
Object.defineProperty(Navigator.prototype, 'plugins', {
|
|
337
|
+
get: function() { return plugins; },
|
|
338
|
+
configurable: true
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {}
|
|
341
|
+
|
|
342
|
+
// 4b) Worker stealth — workers have their own global scope, so navigator.webdriver
|
|
343
|
+
// patches above do NOT propagate. Fingerprint scripts that probe via Worker
|
|
344
|
+
// (e.g. fpscanner.WEBDRIVER) catch this inconsistency. Fix: wrap the Worker
|
|
345
|
+
// constructor to prepend a navigator.webdriver patch via blob URL. Limitations:
|
|
346
|
+
// module workers and same-origin script URLs are best-effort (importScripts
|
|
347
|
+
// doesn't support module workers; cross-origin URLs may bypass via direct fetch).
|
|
348
|
+
try {
|
|
349
|
+
var OrigWorker = window.Worker;
|
|
350
|
+
if (OrigWorker && !window.__cdpilot_worker_patched) {
|
|
351
|
+
var workerPatch = "(function(){try{Object.defineProperty(self.navigator||{},'webdriver',{get:function(){return undefined;},configurable:true});}catch(e){}})();";
|
|
352
|
+
var WrappedWorker = function(scriptURL, options) {
|
|
353
|
+
try {
|
|
354
|
+
var isModule = options && options.type === 'module';
|
|
355
|
+
if (typeof scriptURL === 'string' && !isModule) {
|
|
356
|
+
// Wrap script in a blob that applies the patch then importScripts the original.
|
|
357
|
+
var wrapped = workerPatch + "importScripts(" + JSON.stringify(String(scriptURL)) + ");";
|
|
358
|
+
var blob = new Blob([wrapped], { type: 'application/javascript' });
|
|
359
|
+
scriptURL = URL.createObjectURL(blob);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {}
|
|
362
|
+
return new OrigWorker(scriptURL, options);
|
|
363
|
+
};
|
|
364
|
+
WrappedWorker.prototype = OrigWorker.prototype;
|
|
365
|
+
Object.setPrototypeOf(WrappedWorker, OrigWorker);
|
|
366
|
+
window.Worker = WrappedWorker;
|
|
367
|
+
Object.defineProperty(window, '__cdpilot_worker_patched', { value: true });
|
|
368
|
+
}
|
|
369
|
+
} catch (e) {}
|
|
370
|
+
|
|
371
|
+
// 5) WebGL vendor/renderer — UNMASKED_* params reveal "SwiftShader" in headless.
|
|
372
|
+
// Override returns generic Intel values. Read-only spoof; does not affect rendering.
|
|
373
|
+
try {
|
|
374
|
+
var spoofParam = function(orig, parameter) {
|
|
375
|
+
if (parameter === 37445) return 'Intel Inc.'; // UNMASKED_VENDOR_WEBGL
|
|
376
|
+
if (parameter === 37446) return 'Intel Iris OpenGL Engine'; // UNMASKED_RENDERER_WEBGL
|
|
377
|
+
return orig.call(this, parameter);
|
|
378
|
+
};
|
|
379
|
+
if (window.WebGLRenderingContext) {
|
|
380
|
+
var gp1 = WebGLRenderingContext.prototype.getParameter;
|
|
381
|
+
WebGLRenderingContext.prototype.getParameter = function(p) { return spoofParam.call(this, gp1, p); };
|
|
382
|
+
}
|
|
383
|
+
if (window.WebGL2RenderingContext) {
|
|
384
|
+
var gp2 = WebGL2RenderingContext.prototype.getParameter;
|
|
385
|
+
WebGL2RenderingContext.prototype.getParameter = function(p) { return spoofParam.call(this, gp2, p); };
|
|
386
|
+
}
|
|
387
|
+
} catch (e) {}
|
|
388
|
+
|
|
389
|
+
// 6) navigator.hardwareConcurrency — 0 is a tell. Clamp to a sane default if missing.
|
|
390
|
+
try {
|
|
391
|
+
if (!navigator.hardwareConcurrency || navigator.hardwareConcurrency < 2) {
|
|
392
|
+
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
|
|
393
|
+
get: function() { return 8; },
|
|
394
|
+
configurable: true
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {}
|
|
398
|
+
})();
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
# ─── CAPTCHA Detection ────────────────────────────────────────────────────────
|
|
402
|
+
# Read-only DOM probe. Detects common CAPTCHA/challenge providers post-navigation.
|
|
403
|
+
# Returns JSON string: {"detected": bool, "types": [...], "details": [...]}
|
|
404
|
+
# Pure detection — no DOM mutation, no network, no user data leak.
|
|
405
|
+
CAPTCHA_DETECT_JS = r"""
|
|
406
|
+
(function() {
|
|
407
|
+
try {
|
|
408
|
+
var result = { detected: false, types: [], details: [] };
|
|
409
|
+
var add = function(type, provider, extra) {
|
|
410
|
+
result.detected = true;
|
|
411
|
+
if (result.types.indexOf(type) < 0) result.types.push(type);
|
|
412
|
+
var d = { type: type, provider: provider };
|
|
413
|
+
if (extra) for (var k in extra) d[k] = extra[k];
|
|
414
|
+
result.details.push(d);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Cloudflare Turnstile
|
|
418
|
+
var ts = document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]');
|
|
419
|
+
if (ts.length > 0) add('turnstile', 'Cloudflare', { count: ts.length });
|
|
420
|
+
|
|
421
|
+
// Cloudflare interstitial / managed challenge
|
|
422
|
+
var cfChallenge = document.querySelector('#challenge-form, #challenge-stage, #cf-challenge-running, #cf-please-wait');
|
|
423
|
+
var titleLower = (document.title || '').toLowerCase();
|
|
424
|
+
if (cfChallenge || titleLower.indexOf('just a moment') >= 0 || titleLower.indexOf('attention required') >= 0) {
|
|
425
|
+
add('cloudflare-challenge', 'Cloudflare');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// hCaptcha
|
|
429
|
+
var hc = document.querySelectorAll('iframe[src*="hcaptcha.com"]');
|
|
430
|
+
var hcDiv = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
|
|
431
|
+
if (hc.length > 0 || hcDiv) add('hcaptcha', 'hCaptcha');
|
|
432
|
+
|
|
433
|
+
// reCAPTCHA (v2 visible, v2 invisible, v3, enterprise)
|
|
434
|
+
var rc = document.querySelectorAll('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"]');
|
|
435
|
+
var rcDiv = document.querySelector('.g-recaptcha');
|
|
436
|
+
if (rc.length > 0 || (rcDiv && !hcDiv)) add('recaptcha', 'Google');
|
|
437
|
+
|
|
438
|
+
// DataDome
|
|
439
|
+
if (document.querySelector('#datadome-captcha, iframe[src*="captcha-delivery.com"]')) {
|
|
440
|
+
add('datadome', 'DataDome');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// PerimeterX / HUMAN
|
|
444
|
+
if (document.querySelector('#px-captcha, [class*="px-captcha"]')) {
|
|
445
|
+
add('perimeterx', 'HUMAN');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Arkose Labs (FunCaptcha)
|
|
449
|
+
if (document.querySelector('iframe[src*="arkoselabs.com"], iframe[src*="funcaptcha.com"], #FunCaptcha')) {
|
|
450
|
+
add('arkose', 'Arkose Labs');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// GeeTest
|
|
454
|
+
if (document.querySelector('.geetest_holder, .geetest_panel, [id^="geetest_"]')) {
|
|
455
|
+
add('geetest', 'GeeTest');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return JSON.stringify(result);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
return JSON.stringify({ detected: false, error: String(e) });
|
|
461
|
+
}
|
|
462
|
+
})()
|
|
463
|
+
"""
|
|
464
|
+
|
|
224
465
|
PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
|
|
225
466
|
HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
|
|
467
|
+
STEALTH_CONFIG_FILE = os.path.join(PROFILE_DIR, 'stealth.json')
|
|
226
468
|
DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
|
|
227
469
|
SESSION_FILE = os.path.join(PROFILE_DIR, 'sessions.json')
|
|
228
470
|
|
|
@@ -542,11 +784,21 @@ window.__cdpilot_glow_timeout = setTimeout(function() {
|
|
|
542
784
|
_glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
|
|
543
785
|
|
|
544
786
|
async def _control_start(ws_url):
|
|
545
|
-
"""Enable glow, input blocker,
|
|
787
|
+
"""Enable glow, input blocker, visual feedback, and (if enabled) stealth.
|
|
788
|
+
|
|
789
|
+
Stealth JS is registered via addScriptToEvaluateOnNewDocument so it runs
|
|
790
|
+
BEFORE any page script on the next navigation. It cannot un-fingerprint
|
|
791
|
+
the currently-loaded document — that's by design.
|
|
792
|
+
"""
|
|
546
793
|
global _glow_script_id
|
|
547
794
|
try:
|
|
548
|
-
#
|
|
549
|
-
|
|
795
|
+
# NOTE: addScriptToEvaluateOnNewDocument registered here is session-bound
|
|
796
|
+
# to the WS connection cdp_send opens — it dies when this call returns.
|
|
797
|
+
# That's fine for GLOW (also injected via Runtime.evaluate below) but
|
|
798
|
+
# NOT for stealth, which must run BEFORE page scripts. Stealth is
|
|
799
|
+
# therefore registered inside navigate_collect on the same WS as
|
|
800
|
+
# Page.navigate, so it survives long enough to apply.
|
|
801
|
+
cmds = [(898, "Page.enable", {})]
|
|
550
802
|
if _glow_script_id:
|
|
551
803
|
cmds.append((900, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
|
|
552
804
|
_glow_script_id = None
|
|
@@ -853,6 +1105,18 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
853
1105
|
await ws.send(json.dumps({"id": sid, "method": f"{domain}.enable", "params": {}}))
|
|
854
1106
|
sid += 1
|
|
855
1107
|
|
|
1108
|
+
# Register stealth script BEFORE navigate, on this same WS session.
|
|
1109
|
+
# addScriptToEvaluateOnNewDocument is session-bound: it persists only
|
|
1110
|
+
# as long as this connection lives — which is exactly the window we
|
|
1111
|
+
# need (until Page.loadEventFired below). Stealth must run before
|
|
1112
|
+
# any page script inspects navigator; addScript guarantees that.
|
|
1113
|
+
stealth_active = get_stealth_config()
|
|
1114
|
+
if stealth_active:
|
|
1115
|
+
await ws.send(json.dumps({
|
|
1116
|
+
"id": 50, "method": "Page.addScriptToEvaluateOnNewDocument",
|
|
1117
|
+
"params": {"source": STEALTH_JS}
|
|
1118
|
+
}))
|
|
1119
|
+
|
|
856
1120
|
# Navigate
|
|
857
1121
|
await ws.send(json.dumps({"id": 100, "method": "Page.navigate", "params": {"url": url}}))
|
|
858
1122
|
|
|
@@ -1187,6 +1451,25 @@ def get_headless_config():
|
|
|
1187
1451
|
pass
|
|
1188
1452
|
return False
|
|
1189
1453
|
|
|
1454
|
+
|
|
1455
|
+
def get_stealth_config():
|
|
1456
|
+
"""Return whether stealth fingerprint patches are enabled.
|
|
1457
|
+
|
|
1458
|
+
Default: False (opt-in) — preserves existing behavior, lets users enable
|
|
1459
|
+
only when needed. Some sites detect inconsistent fingerprints and
|
|
1460
|
+
block stealth-mode harder than they would block plain Chrome.
|
|
1461
|
+
"""
|
|
1462
|
+
env = os.environ.get('CDPILOT_STEALTH', '')
|
|
1463
|
+
if env:
|
|
1464
|
+
return env.lower() in ('1', 'true', 'yes', 'on')
|
|
1465
|
+
if os.path.exists(STEALTH_CONFIG_FILE):
|
|
1466
|
+
try:
|
|
1467
|
+
with open(STEALTH_CONFIG_FILE) as f:
|
|
1468
|
+
return bool(json.load(f).get('stealth', False))
|
|
1469
|
+
except (OSError, ValueError):
|
|
1470
|
+
pass
|
|
1471
|
+
return False
|
|
1472
|
+
|
|
1190
1473
|
# ─── Commands ───
|
|
1191
1474
|
|
|
1192
1475
|
def cmd_launch():
|
|
@@ -1315,6 +1598,15 @@ async def cmd_go(url):
|
|
|
1315
1598
|
ws, page = get_page_ws()
|
|
1316
1599
|
content, _ = await navigate_collect(ws, url)
|
|
1317
1600
|
print(content)
|
|
1601
|
+
# Post-navigation CAPTCHA probe. Non-blocking: stderr warning only.
|
|
1602
|
+
# Users can run `cdpilot captcha-wait` to pause for manual solve.
|
|
1603
|
+
try:
|
|
1604
|
+
info = await _detect_captcha(ws)
|
|
1605
|
+
if info.get("detected"):
|
|
1606
|
+
types = ",".join(info.get("types", [])) or "unknown"
|
|
1607
|
+
sys.stderr.write(f"⚠️ CAPTCHA tespit edildi ({types}). Çözüm için: cdpilot captcha-wait\n")
|
|
1608
|
+
except Exception:
|
|
1609
|
+
pass
|
|
1318
1610
|
|
|
1319
1611
|
|
|
1320
1612
|
async def cmd_content():
|
|
@@ -2297,6 +2589,135 @@ def cmd_headless(state=None):
|
|
|
2297
2589
|
print('Restart browser (stop → launch).')
|
|
2298
2590
|
|
|
2299
2591
|
|
|
2592
|
+
def cmd_stealth(state=None):
|
|
2593
|
+
"""Toggle stealth fingerprint patches.
|
|
2594
|
+
|
|
2595
|
+
Usage:
|
|
2596
|
+
cdpilot stealth # show status
|
|
2597
|
+
cdpilot stealth on|off # toggle
|
|
2598
|
+
cdpilot stealth status # show status (alias)
|
|
2599
|
+
|
|
2600
|
+
Patches navigator.webdriver, chrome.runtime, plugins, WebGL vendor and
|
|
2601
|
+
permissions API to defeat the most common automation tells. Effect
|
|
2602
|
+
applies on the NEXT navigation (current pages keep their fingerprint).
|
|
2603
|
+
Zero new dependencies. Disabled by default.
|
|
2604
|
+
"""
|
|
2605
|
+
if state is None or state.lower() == 'status':
|
|
2606
|
+
current = get_stealth_config()
|
|
2607
|
+
print(f'Stealth: {"on" if current else "off"}')
|
|
2608
|
+
if current:
|
|
2609
|
+
print(' Patches: navigator.webdriver, chrome.runtime, plugins, WebGL vendor, permissions, hardwareConcurrency')
|
|
2610
|
+
return
|
|
2611
|
+
|
|
2612
|
+
s = state.lower()
|
|
2613
|
+
if s not in ('on', 'off', '1', '0', 'true', 'false', 'yes', 'no'):
|
|
2614
|
+
print(f"Invalid state: {state}. Use 'on', 'off', or 'status'.", file=sys.stderr)
|
|
2615
|
+
sys.exit(1)
|
|
2616
|
+
enabled = s in ('on', '1', 'true', 'yes')
|
|
2617
|
+
os.makedirs(os.path.dirname(STEALTH_CONFIG_FILE), exist_ok=True)
|
|
2618
|
+
with open(STEALTH_CONFIG_FILE, 'w') as f:
|
|
2619
|
+
json.dump({'stealth': enabled}, f)
|
|
2620
|
+
print(f'Stealth: {"on" if enabled else "off"}')
|
|
2621
|
+
print('Effect applies on next navigation (`cdpilot go <url>`).')
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
async def _detect_captcha(ws_url):
|
|
2625
|
+
"""Run CAPTCHA_DETECT_JS in the active page, return parsed dict.
|
|
2626
|
+
|
|
2627
|
+
Returns: {"detected": bool, "types": [...], "details": [...], "error"?: str}
|
|
2628
|
+
Never raises — failures return {"detected": False, "error": "..."}.
|
|
2629
|
+
"""
|
|
2630
|
+
try:
|
|
2631
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {
|
|
2632
|
+
"expression": CAPTCHA_DETECT_JS,
|
|
2633
|
+
"returnByValue": True,
|
|
2634
|
+
})], timeout=5)
|
|
2635
|
+
raw = r.get(1, {}).get("result", {}).get("value")
|
|
2636
|
+
if not raw:
|
|
2637
|
+
return {"detected": False}
|
|
2638
|
+
try:
|
|
2639
|
+
return json.loads(raw)
|
|
2640
|
+
except (ValueError, TypeError):
|
|
2641
|
+
return {"detected": False, "error": "parse_failed"}
|
|
2642
|
+
except Exception as e:
|
|
2643
|
+
return {"detected": False, "error": str(e)[:120]}
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
async def cmd_captcha_check():
|
|
2647
|
+
"""One-shot CAPTCHA detection on the active page. Prints JSON.
|
|
2648
|
+
|
|
2649
|
+
Exit codes: 0 = no CAPTCHA, 3 = CAPTCHA detected, 1 = error.
|
|
2650
|
+
Useful in scripts: `cdpilot captcha-check && do-stuff`.
|
|
2651
|
+
"""
|
|
2652
|
+
ws, _ = get_page_ws()
|
|
2653
|
+
info = await _detect_captcha(ws)
|
|
2654
|
+
print(json.dumps(info, ensure_ascii=False))
|
|
2655
|
+
if info.get("error") and not info.get("detected"):
|
|
2656
|
+
sys.exit(1)
|
|
2657
|
+
sys.exit(3 if info.get("detected") else 0)
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
async def cmd_captcha_wait(timeout_arg=None):
|
|
2661
|
+
"""Detect CAPTCHA and wait for the user to solve it.
|
|
2662
|
+
|
|
2663
|
+
Behavior:
|
|
2664
|
+
- Interactive (stdin is a TTY): print warning, block on Enter.
|
|
2665
|
+
- Non-interactive (pipe / MCP): poll every 2s until the CAPTCHA
|
|
2666
|
+
disappears or timeout expires; emit one JSON line per poll on
|
|
2667
|
+
state change so callers can stream progress.
|
|
2668
|
+
|
|
2669
|
+
Args:
|
|
2670
|
+
timeout_arg: int seconds (default 300, max 1800). Passed as string from CLI.
|
|
2671
|
+
|
|
2672
|
+
Exit codes: 0 = solved (or none detected), 2 = timeout, 1 = error.
|
|
2673
|
+
"""
|
|
2674
|
+
try:
|
|
2675
|
+
timeout = int(timeout_arg) if timeout_arg else 300
|
|
2676
|
+
except (ValueError, TypeError):
|
|
2677
|
+
print(f"Invalid timeout: {timeout_arg}", file=sys.stderr)
|
|
2678
|
+
sys.exit(1)
|
|
2679
|
+
timeout = max(5, min(timeout, 1800))
|
|
2680
|
+
|
|
2681
|
+
ws, _ = get_page_ws()
|
|
2682
|
+
info = await _detect_captcha(ws)
|
|
2683
|
+
if not info.get("detected"):
|
|
2684
|
+
print(json.dumps({"detected": False, "status": "none"}, ensure_ascii=False))
|
|
2685
|
+
return
|
|
2686
|
+
|
|
2687
|
+
types = ",".join(info.get("types", [])) or "unknown"
|
|
2688
|
+
interactive = sys.stdin.isatty() and not IS_MCP_SESSION
|
|
2689
|
+
|
|
2690
|
+
if interactive:
|
|
2691
|
+
sys.stderr.write(f"\n⚠️ CAPTCHA tespit edildi: {types}\n")
|
|
2692
|
+
sys.stderr.write(" Tarayıcıda çözün, ardından Enter'a basın (Ctrl+C iptal)...\n")
|
|
2693
|
+
sys.stderr.flush()
|
|
2694
|
+
try:
|
|
2695
|
+
input()
|
|
2696
|
+
except (EOFError, KeyboardInterrupt):
|
|
2697
|
+
print(json.dumps({"detected": True, "status": "cancelled", "types": info.get("types", [])}, ensure_ascii=False))
|
|
2698
|
+
sys.exit(1)
|
|
2699
|
+
# Verify it's gone
|
|
2700
|
+
post = await _detect_captcha(ws)
|
|
2701
|
+
if post.get("detected"):
|
|
2702
|
+
print(json.dumps({"detected": True, "status": "still_present", "types": post.get("types", [])}, ensure_ascii=False))
|
|
2703
|
+
sys.exit(2)
|
|
2704
|
+
print(json.dumps({"detected": False, "status": "solved"}, ensure_ascii=False))
|
|
2705
|
+
return
|
|
2706
|
+
|
|
2707
|
+
# Non-interactive: poll until solved or timeout
|
|
2708
|
+
print(json.dumps({"detected": True, "status": "waiting", "types": info.get("types", []), "timeout": timeout}, ensure_ascii=False), flush=True)
|
|
2709
|
+
deadline = time.time() + timeout
|
|
2710
|
+
poll_interval = 2.0
|
|
2711
|
+
while time.time() < deadline:
|
|
2712
|
+
await asyncio.sleep(poll_interval)
|
|
2713
|
+
post = await _detect_captcha(ws)
|
|
2714
|
+
if not post.get("detected"):
|
|
2715
|
+
print(json.dumps({"detected": False, "status": "solved", "elapsed": round(timeout - (deadline - time.time()), 1)}, ensure_ascii=False), flush=True)
|
|
2716
|
+
return
|
|
2717
|
+
print(json.dumps({"detected": True, "status": "timeout", "types": info.get("types", []), "waited": timeout}, ensure_ascii=False), flush=True)
|
|
2718
|
+
sys.exit(2)
|
|
2719
|
+
|
|
2720
|
+
|
|
2300
2721
|
def _stop_browser_on_port(port):
|
|
2301
2722
|
"""Stop the browser process listening on the given port."""
|
|
2302
2723
|
import signal
|
|
@@ -2828,6 +3249,509 @@ async def cmd_describe():
|
|
|
2828
3249
|
print(page_text[:2000])
|
|
2829
3250
|
|
|
2830
3251
|
|
|
3252
|
+
# ─── Data Extraction & Observation Commands ───
|
|
3253
|
+
|
|
3254
|
+
async def cmd_extract(selector, output_format="text"):
|
|
3255
|
+
"""Extract structured data from elements matching selector.
|
|
3256
|
+
|
|
3257
|
+
Returns text content, attributes, or full JSON structure.
|
|
3258
|
+
No LLM required — pure DOM extraction.
|
|
3259
|
+
|
|
3260
|
+
Usage:
|
|
3261
|
+
cdpilot extract "table tr" → text rows
|
|
3262
|
+
cdpilot extract ".product" --json → full JSON (tag, text, attrs, children)
|
|
3263
|
+
cdpilot extract "a" --attrs=href,title → specific attributes
|
|
3264
|
+
cdpilot extract "ul li" --list → clean list output
|
|
3265
|
+
"""
|
|
3266
|
+
ws_url, _ = get_page_ws()
|
|
3267
|
+
safe_sel = json.dumps(selector)
|
|
3268
|
+
|
|
3269
|
+
if output_format == "json":
|
|
3270
|
+
js = f"""
|
|
3271
|
+
(function() {{
|
|
3272
|
+
var els = document.querySelectorAll({safe_sel});
|
|
3273
|
+
if (!els.length) return JSON.stringify([]);
|
|
3274
|
+
return JSON.stringify(Array.from(els).slice(0, 100).map(function(el) {{
|
|
3275
|
+
var attrs = {{}};
|
|
3276
|
+
for (var i = 0; i < el.attributes.length; i++) {{
|
|
3277
|
+
attrs[el.attributes[i].name] = el.attributes[i].value;
|
|
3278
|
+
}}
|
|
3279
|
+
return {{
|
|
3280
|
+
tag: el.tagName.toLowerCase(),
|
|
3281
|
+
text: (el.textContent || '').trim().substring(0, 500),
|
|
3282
|
+
attrs: attrs,
|
|
3283
|
+
value: el.value || null,
|
|
3284
|
+
href: el.href || null,
|
|
3285
|
+
src: el.src || null
|
|
3286
|
+
}};
|
|
3287
|
+
}}));
|
|
3288
|
+
}})()
|
|
3289
|
+
"""
|
|
3290
|
+
elif output_format.startswith("attrs="):
|
|
3291
|
+
attr_names = output_format.split("=", 1)[1].split(",")
|
|
3292
|
+
attrs_js = ",".join(f'"{a}": el.getAttribute("{a}")' for a in attr_names)
|
|
3293
|
+
js = f"""
|
|
3294
|
+
(function() {{
|
|
3295
|
+
var els = document.querySelectorAll({safe_sel});
|
|
3296
|
+
if (!els.length) return JSON.stringify([]);
|
|
3297
|
+
return JSON.stringify(Array.from(els).slice(0, 100).map(function(el) {{
|
|
3298
|
+
return {{ {attrs_js} }};
|
|
3299
|
+
}}));
|
|
3300
|
+
}})()
|
|
3301
|
+
"""
|
|
3302
|
+
elif output_format == "list":
|
|
3303
|
+
js = f"""
|
|
3304
|
+
(function() {{
|
|
3305
|
+
var els = document.querySelectorAll({safe_sel});
|
|
3306
|
+
if (!els.length) return '';
|
|
3307
|
+
return Array.from(els).slice(0, 200).map(function(el, i) {{
|
|
3308
|
+
return (i + 1) + '. ' + (el.textContent || '').trim().substring(0, 200);
|
|
3309
|
+
}}).join('\\n');
|
|
3310
|
+
}})()
|
|
3311
|
+
"""
|
|
3312
|
+
else:
|
|
3313
|
+
# Default: text content, one per line
|
|
3314
|
+
js = f"""
|
|
3315
|
+
(function() {{
|
|
3316
|
+
var els = document.querySelectorAll({safe_sel});
|
|
3317
|
+
if (!els.length) return '';
|
|
3318
|
+
return Array.from(els).slice(0, 200).map(function(el) {{
|
|
3319
|
+
return (el.textContent || '').trim().substring(0, 300);
|
|
3320
|
+
}}).filter(function(t) {{ return t; }}).join('\\n');
|
|
3321
|
+
}})()
|
|
3322
|
+
"""
|
|
3323
|
+
|
|
3324
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3325
|
+
result = r.get(1, {}).get("result", {}).get("value", "")
|
|
3326
|
+
if not result:
|
|
3327
|
+
print(f"No elements found: {selector}", file=sys.stderr)
|
|
3328
|
+
return
|
|
3329
|
+
if output_format in ("json",) or output_format.startswith("attrs="):
|
|
3330
|
+
# Pretty print JSON
|
|
3331
|
+
try:
|
|
3332
|
+
parsed = json.loads(result)
|
|
3333
|
+
print(json.dumps(parsed, indent=2, ensure_ascii=False))
|
|
3334
|
+
except (json.JSONDecodeError, TypeError):
|
|
3335
|
+
print(result)
|
|
3336
|
+
else:
|
|
3337
|
+
print(result)
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
async def cmd_observe():
|
|
3341
|
+
"""List all interactive elements on the page with actions.
|
|
3342
|
+
|
|
3343
|
+
Like Stagehand's observe() but deterministic — no LLM needed.
|
|
3344
|
+
Shows what you CAN DO on the current page.
|
|
3345
|
+
"""
|
|
3346
|
+
ws_url, _ = get_page_ws()
|
|
3347
|
+
js = """
|
|
3348
|
+
(function() {
|
|
3349
|
+
var results = [];
|
|
3350
|
+
var els = document.querySelectorAll(
|
|
3351
|
+
'a, button, input, textarea, select, [role=button], [role=link], ' +
|
|
3352
|
+
'[role=tab], [role=menuitem], [role=checkbox], [role=radio], ' +
|
|
3353
|
+
'[onclick], [tabindex]:not([tabindex="-1"])'
|
|
3354
|
+
);
|
|
3355
|
+
var seen = new Set();
|
|
3356
|
+
Array.from(els).forEach(function(el, i) {
|
|
3357
|
+
if (i >= 50) return;
|
|
3358
|
+
var rect = el.getBoundingClientRect();
|
|
3359
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
3360
|
+
var style = window.getComputedStyle(el);
|
|
3361
|
+
if (style.display === 'none' || style.visibility === 'hidden') return;
|
|
3362
|
+
|
|
3363
|
+
var tag = el.tagName.toLowerCase();
|
|
3364
|
+
var type = el.type || '';
|
|
3365
|
+
var role = el.getAttribute('role') || '';
|
|
3366
|
+
var text = (el.textContent || el.value || el.placeholder || '').trim().substring(0, 60);
|
|
3367
|
+
var href = el.href || '';
|
|
3368
|
+
var name = el.name || el.id || '';
|
|
3369
|
+
|
|
3370
|
+
// Determine action
|
|
3371
|
+
var action = 'click';
|
|
3372
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
3373
|
+
if (type === 'checkbox' || type === 'radio') action = 'toggle';
|
|
3374
|
+
else if (type === 'submit') action = 'submit';
|
|
3375
|
+
else if (type === 'file') action = 'upload';
|
|
3376
|
+
else action = 'fill';
|
|
3377
|
+
} else if (tag === 'select') {
|
|
3378
|
+
action = 'select';
|
|
3379
|
+
} else if (tag === 'a') {
|
|
3380
|
+
action = 'navigate';
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
// Build selector
|
|
3384
|
+
var sel = '';
|
|
3385
|
+
if (el.id) sel = '#' + el.id;
|
|
3386
|
+
else if (name) sel = tag + '[name=' + JSON.stringify(name) + ']';
|
|
3387
|
+
else if (type) sel = tag + '[type=' + type + ']';
|
|
3388
|
+
else sel = tag;
|
|
3389
|
+
|
|
3390
|
+
var key = action + ':' + sel + ':' + text;
|
|
3391
|
+
if (seen.has(key)) return;
|
|
3392
|
+
seen.add(key);
|
|
3393
|
+
|
|
3394
|
+
var line = action.toUpperCase() + ' ' + sel;
|
|
3395
|
+
if (text) line += ' "' + text + '"';
|
|
3396
|
+
if (href && action === 'navigate') line += ' → ' + href.substring(0, 80);
|
|
3397
|
+
results.push(line);
|
|
3398
|
+
});
|
|
3399
|
+
return results.join('\\n') || 'No interactive elements found';
|
|
3400
|
+
})()
|
|
3401
|
+
"""
|
|
3402
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3403
|
+
result = r.get(1, {}).get("result", {}).get("value", "No interactive elements found")
|
|
3404
|
+
print(f"=== What you can do on this page ===\n")
|
|
3405
|
+
print(result)
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
# ─── Smart Commands (LLM-free intelligence) ───
|
|
3409
|
+
|
|
3410
|
+
async def cmd_smart_click(text):
|
|
3411
|
+
"""Click element by visible text — fuzzy matching, no CSS selector needed.
|
|
3412
|
+
|
|
3413
|
+
Like Stagehand's act("Click login") but without LLM.
|
|
3414
|
+
Searches: button text, link text, aria-label, placeholder, title, value.
|
|
3415
|
+
|
|
3416
|
+
Usage:
|
|
3417
|
+
cdpilot smart-click "Login"
|
|
3418
|
+
cdpilot smart-click "Submit Order"
|
|
3419
|
+
cdpilot smart-click "Learn more"
|
|
3420
|
+
"""
|
|
3421
|
+
ws_url, _ = get_page_ws()
|
|
3422
|
+
safe_text = json.dumps(text.lower())
|
|
3423
|
+
js = f"""
|
|
3424
|
+
(function() {{
|
|
3425
|
+
var search = {safe_text};
|
|
3426
|
+
var candidates = [];
|
|
3427
|
+
|
|
3428
|
+
// Score: exact > startsWith > includes > partial
|
|
3429
|
+
function score(str) {{
|
|
3430
|
+
if (!str) return 0;
|
|
3431
|
+
var s = str.toLowerCase().trim();
|
|
3432
|
+
if (s === search) return 100;
|
|
3433
|
+
if (s.startsWith(search)) return 80;
|
|
3434
|
+
if (s.includes(search)) return 60;
|
|
3435
|
+
// Partial word match
|
|
3436
|
+
var words = search.split(/\\s+/);
|
|
3437
|
+
var matched = words.filter(function(w) {{ return s.includes(w); }}).length;
|
|
3438
|
+
if (matched > 0) return 20 + (matched / words.length) * 30;
|
|
3439
|
+
return 0;
|
|
3440
|
+
}}
|
|
3441
|
+
|
|
3442
|
+
var els = document.querySelectorAll(
|
|
3443
|
+
'a, button, input[type=submit], input[type=button], ' +
|
|
3444
|
+
'[role=button], [role=link], [role=tab], [role=menuitem], ' +
|
|
3445
|
+
'summary, label, [onclick], [tabindex]'
|
|
3446
|
+
);
|
|
3447
|
+
|
|
3448
|
+
Array.from(els).forEach(function(el) {{
|
|
3449
|
+
var rect = el.getBoundingClientRect();
|
|
3450
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
3451
|
+
var style = window.getComputedStyle(el);
|
|
3452
|
+
if (style.display === 'none' || style.visibility === 'hidden') return;
|
|
3453
|
+
|
|
3454
|
+
var texts = [
|
|
3455
|
+
el.textContent || '',
|
|
3456
|
+
el.getAttribute('aria-label') || '',
|
|
3457
|
+
el.getAttribute('title') || '',
|
|
3458
|
+
el.getAttribute('placeholder') || '',
|
|
3459
|
+
el.value || '',
|
|
3460
|
+
el.getAttribute('alt') || ''
|
|
3461
|
+
];
|
|
3462
|
+
|
|
3463
|
+
var bestScore = 0;
|
|
3464
|
+
var bestMatch = '';
|
|
3465
|
+
texts.forEach(function(t) {{
|
|
3466
|
+
var s = score(t);
|
|
3467
|
+
if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
|
|
3468
|
+
}});
|
|
3469
|
+
|
|
3470
|
+
if (bestScore > 0) {{
|
|
3471
|
+
candidates.push({{
|
|
3472
|
+
el: el,
|
|
3473
|
+
score: bestScore,
|
|
3474
|
+
match: bestMatch,
|
|
3475
|
+
tag: el.tagName.toLowerCase(),
|
|
3476
|
+
cx: rect.x + rect.width / 2,
|
|
3477
|
+
cy: rect.y + rect.height / 2
|
|
3478
|
+
}});
|
|
3479
|
+
}}
|
|
3480
|
+
}});
|
|
3481
|
+
|
|
3482
|
+
if (candidates.length === 0) return JSON.stringify({{found: false}});
|
|
3483
|
+
|
|
3484
|
+
candidates.sort(function(a, b) {{ return b.score - a.score; }});
|
|
3485
|
+
var best = candidates[0];
|
|
3486
|
+
best.el.scrollIntoView({{block: 'center'}});
|
|
3487
|
+
var rect = best.el.getBoundingClientRect();
|
|
3488
|
+
best.el.click();
|
|
3489
|
+
return JSON.stringify({{
|
|
3490
|
+
found: true,
|
|
3491
|
+
tag: best.tag,
|
|
3492
|
+
text: best.match,
|
|
3493
|
+
score: best.score,
|
|
3494
|
+
x: rect.x + rect.width / 2,
|
|
3495
|
+
y: rect.y + rect.height / 2,
|
|
3496
|
+
alternatives: candidates.slice(1, 4).map(function(c) {{
|
|
3497
|
+
return c.tag + ' "' + c.match + '" (score:' + c.score + ')';
|
|
3498
|
+
}})
|
|
3499
|
+
}});
|
|
3500
|
+
}})()
|
|
3501
|
+
"""
|
|
3502
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3503
|
+
raw = r.get(1, {}).get("result", {}).get("value", "")
|
|
3504
|
+
try:
|
|
3505
|
+
data = json.loads(raw)
|
|
3506
|
+
except (json.JSONDecodeError, TypeError):
|
|
3507
|
+
print(f"Error parsing result", file=sys.stderr)
|
|
3508
|
+
return
|
|
3509
|
+
|
|
3510
|
+
if not data.get("found"):
|
|
3511
|
+
print(f'No element found matching: "{text}"', file=sys.stderr)
|
|
3512
|
+
sys.exit(1)
|
|
3513
|
+
|
|
3514
|
+
await _vfx_ripple(ws_url, data["x"], data["y"])
|
|
3515
|
+
print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
|
|
3516
|
+
if data.get("alternatives"):
|
|
3517
|
+
print(f' Also found: {", ".join(data["alternatives"])}')
|
|
3518
|
+
|
|
3519
|
+
|
|
3520
|
+
async def cmd_smart_fill(text, value):
|
|
3521
|
+
"""Fill input by label/placeholder text — no CSS selector needed.
|
|
3522
|
+
|
|
3523
|
+
Finds input by: associated label, placeholder, aria-label, name, id match.
|
|
3524
|
+
|
|
3525
|
+
Usage:
|
|
3526
|
+
cdpilot smart-fill "Email" "test@example.com"
|
|
3527
|
+
cdpilot smart-fill "Password" "secret123"
|
|
3528
|
+
cdpilot smart-fill "Search" "cdpilot"
|
|
3529
|
+
"""
|
|
3530
|
+
ws_url, _ = get_page_ws()
|
|
3531
|
+
safe_text = json.dumps(text.lower())
|
|
3532
|
+
safe_value = json.dumps(value)
|
|
3533
|
+
js = f"""
|
|
3534
|
+
(function() {{
|
|
3535
|
+
var search = {safe_text};
|
|
3536
|
+
var value = {safe_value};
|
|
3537
|
+
var candidates = [];
|
|
3538
|
+
|
|
3539
|
+
function score(str) {{
|
|
3540
|
+
if (!str) return 0;
|
|
3541
|
+
var s = str.toLowerCase().trim();
|
|
3542
|
+
if (s === search) return 100;
|
|
3543
|
+
if (s.startsWith(search)) return 80;
|
|
3544
|
+
if (s.includes(search)) return 60;
|
|
3545
|
+
return 0;
|
|
3546
|
+
}}
|
|
3547
|
+
|
|
3548
|
+
var inputs = document.querySelectorAll('input, textarea, select, [contenteditable=true]');
|
|
3549
|
+
Array.from(inputs).forEach(function(el) {{
|
|
3550
|
+
var rect = el.getBoundingClientRect();
|
|
3551
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
3552
|
+
|
|
3553
|
+
var scores = [];
|
|
3554
|
+
// Check placeholder
|
|
3555
|
+
scores.push(score(el.getAttribute('placeholder') || ''));
|
|
3556
|
+
// Check aria-label
|
|
3557
|
+
scores.push(score(el.getAttribute('aria-label') || ''));
|
|
3558
|
+
// Check name/id
|
|
3559
|
+
scores.push(score(el.name || ''));
|
|
3560
|
+
scores.push(score(el.id || ''));
|
|
3561
|
+
// Check associated label
|
|
3562
|
+
if (el.id) {{
|
|
3563
|
+
var label = document.querySelector('label[for="' + el.id + '"]');
|
|
3564
|
+
if (label) scores.push(score(label.textContent || ''));
|
|
3565
|
+
}}
|
|
3566
|
+
// Check parent label
|
|
3567
|
+
var parentLabel = el.closest('label');
|
|
3568
|
+
if (parentLabel) scores.push(score(parentLabel.textContent || ''));
|
|
3569
|
+
// Check preceding text node/label
|
|
3570
|
+
var prev = el.previousElementSibling;
|
|
3571
|
+
if (prev) scores.push(score(prev.textContent || ''));
|
|
3572
|
+
|
|
3573
|
+
var bestScore = Math.max.apply(null, scores);
|
|
3574
|
+
if (bestScore > 0) {{
|
|
3575
|
+
candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
|
|
3576
|
+
}}
|
|
3577
|
+
}});
|
|
3578
|
+
|
|
3579
|
+
if (candidates.length === 0) return JSON.stringify({{found: false}});
|
|
3580
|
+
|
|
3581
|
+
candidates.sort(function(a, b) {{ return b.score - a.score; }});
|
|
3582
|
+
var best = candidates[0];
|
|
3583
|
+
|
|
3584
|
+
// React-compatible value setting
|
|
3585
|
+
var nativeSetter = Object.getOwnPropertyDescriptor(
|
|
3586
|
+
window.HTMLInputElement.prototype, 'value'
|
|
3587
|
+
) || Object.getOwnPropertyDescriptor(
|
|
3588
|
+
window.HTMLTextAreaElement.prototype, 'value'
|
|
3589
|
+
);
|
|
3590
|
+
if (nativeSetter && nativeSetter.set) {{
|
|
3591
|
+
nativeSetter.set.call(best.el, value);
|
|
3592
|
+
}} else {{
|
|
3593
|
+
best.el.value = value;
|
|
3594
|
+
}}
|
|
3595
|
+
best.el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
3596
|
+
best.el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
3597
|
+
|
|
3598
|
+
return JSON.stringify({{
|
|
3599
|
+
found: true,
|
|
3600
|
+
tag: best.tag,
|
|
3601
|
+
type: best.type,
|
|
3602
|
+
score: best.score,
|
|
3603
|
+
placeholder: best.el.getAttribute('placeholder') || '',
|
|
3604
|
+
name: best.el.name || best.el.id || ''
|
|
3605
|
+
}});
|
|
3606
|
+
}})()
|
|
3607
|
+
"""
|
|
3608
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3609
|
+
raw = r.get(1, {}).get("result", {}).get("value", "")
|
|
3610
|
+
try:
|
|
3611
|
+
data = json.loads(raw)
|
|
3612
|
+
except (json.JSONDecodeError, TypeError):
|
|
3613
|
+
print(f"Error parsing result", file=sys.stderr)
|
|
3614
|
+
return
|
|
3615
|
+
|
|
3616
|
+
if not data.get("found"):
|
|
3617
|
+
print(f'No input found matching: "{text}"', file=sys.stderr)
|
|
3618
|
+
sys.exit(1)
|
|
3619
|
+
|
|
3620
|
+
ident = data.get("placeholder") or data.get("name") or data["tag"]
|
|
3621
|
+
print(f'Filled: {data["tag"].upper()}[{data["type"]}] "{ident}" = {value} (score:{data["score"]})')
|
|
3622
|
+
|
|
3623
|
+
|
|
3624
|
+
async def cmd_smart_select(text, option_text):
|
|
3625
|
+
"""Select dropdown option by label text — no CSS selector needed.
|
|
3626
|
+
|
|
3627
|
+
Usage:
|
|
3628
|
+
cdpilot smart-select "Country" "Turkey"
|
|
3629
|
+
cdpilot smart-select "Size" "Large"
|
|
3630
|
+
"""
|
|
3631
|
+
ws_url, _ = get_page_ws()
|
|
3632
|
+
safe_text = json.dumps(text.lower())
|
|
3633
|
+
safe_option = json.dumps(option_text.lower())
|
|
3634
|
+
js = f"""
|
|
3635
|
+
(function() {{
|
|
3636
|
+
var search = {safe_text};
|
|
3637
|
+
var optSearch = {safe_option};
|
|
3638
|
+
var selects = document.querySelectorAll('select');
|
|
3639
|
+
var best = null;
|
|
3640
|
+
var bestScore = 0;
|
|
3641
|
+
|
|
3642
|
+
Array.from(selects).forEach(function(sel) {{
|
|
3643
|
+
var texts = [
|
|
3644
|
+
sel.getAttribute('aria-label') || '',
|
|
3645
|
+
sel.name || '', sel.id || ''
|
|
3646
|
+
];
|
|
3647
|
+
if (sel.id) {{
|
|
3648
|
+
var label = document.querySelector('label[for="' + sel.id + '"]');
|
|
3649
|
+
if (label) texts.push(label.textContent || '');
|
|
3650
|
+
}}
|
|
3651
|
+
var parent = sel.closest('label');
|
|
3652
|
+
if (parent) texts.push(parent.textContent || '');
|
|
3653
|
+
|
|
3654
|
+
texts.forEach(function(t) {{
|
|
3655
|
+
var s = t.toLowerCase().trim();
|
|
3656
|
+
var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
|
|
3657
|
+
if (sc > bestScore) {{ bestScore = sc; best = sel; }}
|
|
3658
|
+
}});
|
|
3659
|
+
}});
|
|
3660
|
+
|
|
3661
|
+
if (!best) return JSON.stringify({{found: false}});
|
|
3662
|
+
|
|
3663
|
+
// Find matching option
|
|
3664
|
+
var options = Array.from(best.options);
|
|
3665
|
+
var match = options.find(function(o) {{
|
|
3666
|
+
return o.text.toLowerCase().trim() === optSearch;
|
|
3667
|
+
}}) || options.find(function(o) {{
|
|
3668
|
+
return o.text.toLowerCase().includes(optSearch);
|
|
3669
|
+
}});
|
|
3670
|
+
|
|
3671
|
+
if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
|
|
3672
|
+
|
|
3673
|
+
best.value = match.value;
|
|
3674
|
+
best.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
3675
|
+
return JSON.stringify({{found: true, optionFound: true, selected: match.text, value: match.value}});
|
|
3676
|
+
}})()
|
|
3677
|
+
"""
|
|
3678
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3679
|
+
raw = r.get(1, {}).get("result", {}).get("value", "")
|
|
3680
|
+
try:
|
|
3681
|
+
data = json.loads(raw)
|
|
3682
|
+
except (json.JSONDecodeError, TypeError):
|
|
3683
|
+
print("Error parsing result", file=sys.stderr)
|
|
3684
|
+
return
|
|
3685
|
+
|
|
3686
|
+
if not data.get("found"):
|
|
3687
|
+
print(f'No select found matching: "{text}"', file=sys.stderr)
|
|
3688
|
+
sys.exit(1)
|
|
3689
|
+
if not data.get("optionFound"):
|
|
3690
|
+
print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
|
|
3691
|
+
sys.exit(1)
|
|
3692
|
+
print(f'Selected: "{data["selected"]}" (value={data["value"]})')
|
|
3693
|
+
|
|
3694
|
+
|
|
3695
|
+
async def cmd_run_script(script_path):
|
|
3696
|
+
"""Run a .cdp script file — sequential commands, one per line.
|
|
3697
|
+
|
|
3698
|
+
Script format (plain text):
|
|
3699
|
+
go https://example.com
|
|
3700
|
+
wait-for h1
|
|
3701
|
+
assert h1 "Example Domain"
|
|
3702
|
+
click a
|
|
3703
|
+
shot /tmp/result.png
|
|
3704
|
+
|
|
3705
|
+
Lines starting with # are comments. Empty lines are skipped.
|
|
3706
|
+
"""
|
|
3707
|
+
if not os.path.exists(script_path):
|
|
3708
|
+
print(f"Script not found: {script_path}", file=sys.stderr)
|
|
3709
|
+
sys.exit(1)
|
|
3710
|
+
|
|
3711
|
+
with open(script_path) as f:
|
|
3712
|
+
lines = f.readlines()
|
|
3713
|
+
|
|
3714
|
+
import shlex
|
|
3715
|
+
passed = 0
|
|
3716
|
+
failed = 0
|
|
3717
|
+
for line_num, line in enumerate(lines, 1):
|
|
3718
|
+
line = line.strip()
|
|
3719
|
+
if not line or line.startswith("#"):
|
|
3720
|
+
continue
|
|
3721
|
+
try:
|
|
3722
|
+
parts = shlex.split(line)
|
|
3723
|
+
except ValueError:
|
|
3724
|
+
parts = line.split()
|
|
3725
|
+
cmd_name = parts[0]
|
|
3726
|
+
cmd_args = parts[1:]
|
|
3727
|
+
|
|
3728
|
+
print(f"[{line_num}] {line}")
|
|
3729
|
+
try:
|
|
3730
|
+
result = subprocess.run(
|
|
3731
|
+
[sys.executable, __file__] + parts,
|
|
3732
|
+
capture_output=True, text=True, timeout=30,
|
|
3733
|
+
env={**os.environ, "CDPILOT_MCP_SESSION": "1"}
|
|
3734
|
+
)
|
|
3735
|
+
output = result.stdout.strip()
|
|
3736
|
+
if output:
|
|
3737
|
+
for out_line in output.split("\n"):
|
|
3738
|
+
print(f" {out_line}")
|
|
3739
|
+
if result.returncode != 0:
|
|
3740
|
+
err = result.stderr.strip()
|
|
3741
|
+
if err:
|
|
3742
|
+
print(f" ERROR: {err}")
|
|
3743
|
+
failed += 1
|
|
3744
|
+
else:
|
|
3745
|
+
passed += 1
|
|
3746
|
+
except subprocess.TimeoutExpired:
|
|
3747
|
+
print(f" TIMEOUT")
|
|
3748
|
+
failed += 1
|
|
3749
|
+
|
|
3750
|
+
print(f"\n{'─' * 40}")
|
|
3751
|
+
print(f"Script: {script_path}")
|
|
3752
|
+
print(f"Result: {passed} passed, {failed} failed, {passed + failed} total")
|
|
3753
|
+
|
|
3754
|
+
|
|
2831
3755
|
# ─── Testing Commands ───
|
|
2832
3756
|
|
|
2833
3757
|
async def cmd_assert(selector, expected_text=None, check_visible=True):
|
|
@@ -3658,6 +4582,16 @@ class MCPServer:
|
|
|
3658
4582
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
3659
4583
|
{"name": "browser_close", "description": "Close the currently active browser tab. Use this to clean up after automation tasks or to close unwanted popups/tabs that were opened during navigation.",
|
|
3660
4584
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
4585
|
+
{"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.",
|
|
4586
|
+
"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"]}},
|
|
4587
|
+
{"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.",
|
|
4588
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
4589
|
+
{"name": "browser_smart_click", "description": "Click an element by its visible text — no CSS selector needed. Uses fuzzy matching across text content, aria-label, title, placeholder, and value. Returns match score and alternatives. Like Stagehand act('Click login') but without LLM cost. Use when you know WHAT to click but not the exact selector.",
|
|
4590
|
+
"inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "Visible text of the element to click (e.g. 'Login', 'Submit Order', 'Learn more')"}}, "required": ["text"]}},
|
|
4591
|
+
{"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.",
|
|
4592
|
+
"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"]}},
|
|
4593
|
+
{"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.",
|
|
4594
|
+
"inputSchema": {"type": "object", "properties": {"label": {"type": "string", "description": "Label text of the select dropdown"}, "option": {"type": "string", "description": "Text of the option to select"}}, "required": ["label", "option"]}},
|
|
3661
4595
|
{"name": "browser_describe", "description": "Get a comprehensive page description combining three data sources: (1) accessibility tree with @N references for interactive elements, (2) a PNG screenshot saved to disk, and (3) visible text content. Use this when browser_a11y alone is insufficient — for canvas/WebGL content, visual verification, or complex dynamic UIs. This is the vision fallback tool.",
|
|
3662
4596
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
3663
4597
|
{"name": "browser_assert", "description": "Assert that an element matching the CSS selector exists and optionally contains expected text. Returns PASS or FAIL with details. Use this for automated testing — verify page state after navigation or interaction. Checks visibility by default.",
|
|
@@ -3732,6 +4666,11 @@ class MCPServer:
|
|
|
3732
4666
|
"browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
|
|
3733
4667
|
"browser_launch": lambda a: ["launch"],
|
|
3734
4668
|
"browser_close": lambda a: ["close"],
|
|
4669
|
+
"browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
|
|
4670
|
+
"browser_observe": lambda a: ["observe"],
|
|
4671
|
+
"browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
|
|
4672
|
+
"browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
|
|
4673
|
+
"browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
|
|
3735
4674
|
"browser_describe": lambda a: ["describe"],
|
|
3736
4675
|
"browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
|
|
3737
4676
|
"browser_wait_for": lambda a: ["wait-for", a.get("selector", "")] + ([str(a["timeout"])] if a.get("timeout") else []),
|
|
@@ -3816,6 +4755,7 @@ if __name__ == "__main__":
|
|
|
3816
4755
|
'version': cmd_version,
|
|
3817
4756
|
'proxy': lambda: cmd_proxy(args[0] if args else None),
|
|
3818
4757
|
'headless': lambda: cmd_headless(args[0] if args else None),
|
|
4758
|
+
'stealth': lambda: cmd_stealth(args[0] if args else None),
|
|
3819
4759
|
'session': cmd_session,
|
|
3820
4760
|
'sessions': cmd_sessions,
|
|
3821
4761
|
'session-close': lambda: cmd_session_close(args[0] if args else None),
|
|
@@ -3896,6 +4836,12 @@ if __name__ == "__main__":
|
|
|
3896
4836
|
'a11y': lambda: cmd_a11y(' '.join(args)),
|
|
3897
4837
|
'a11y-snapshot': cmd_a11y_snapshot,
|
|
3898
4838
|
'describe': cmd_describe,
|
|
4839
|
+
'extract': lambda: (require_args(1, 'extract <selector> [--json|--list|--attrs=href,title]'), None)[1] if not args else cmd_extract(args[0], next((a.lstrip('-') for a in args[1:] if a.startswith('--')), "text")),
|
|
4840
|
+
'observe': cmd_observe,
|
|
4841
|
+
'run': lambda: (require_args(1, 'run <script.cdp>'), None)[1] if not args else cmd_run_script(args[0]),
|
|
4842
|
+
'smart-click': lambda: (require_args(1, 'smart-click <text>'), None)[1] if not args else cmd_smart_click(" ".join(args)),
|
|
4843
|
+
'smart-fill': lambda: (require_args(2, 'smart-fill <label> <value>'), None)[1] if len(args) < 2 else cmd_smart_fill(args[0], " ".join(args[1:])),
|
|
4844
|
+
'smart-select': lambda: (require_args(2, 'smart-select <label> <option>'), None)[1] if len(args) < 2 else cmd_smart_select(args[0], " ".join(args[1:])),
|
|
3899
4845
|
'assert': lambda: (require_args(1, 'assert <selector> [text]'), None)[1] if not args else cmd_assert(args[0], args[1] if len(args) > 1 else None),
|
|
3900
4846
|
'wait-for': lambda: (require_args(1, 'wait-for <selector> [timeout_ms]'), None)[1] if not args else cmd_wait_for(args[0], int(args[1]) if len(args) > 1 else 5000),
|
|
3901
4847
|
'check': lambda: cmd_check(args[0] if args else None),
|
|
@@ -3920,12 +4866,15 @@ if __name__ == "__main__":
|
|
|
3920
4866
|
'throttle': lambda: (require_args(1, 'throttle [slow3g|fast3g|offline|off|custom <down> <up> <lat>]'), None)[1] if not args else cmd_throttle(args[0], *args[1:]),
|
|
3921
4867
|
'geo': lambda: (require_args(1, 'geo [<lat> <lng>|istanbul|london|newyork|off]'), None)[1] if not args else cmd_geo(args[0], args[1] if len(args) > 1 else None, args[2] if len(args) > 2 else None),
|
|
3922
4868
|
'permission': lambda: (require_args(1, 'permission [grant|deny|reset] [<permission>]'), None)[1] if not args else cmd_permission(args[0], args[1] if len(args) > 1 else None),
|
|
4869
|
+
'captcha-check': cmd_captcha_check,
|
|
4870
|
+
'captcha-wait': lambda: cmd_captcha_wait(args[0] if args else None),
|
|
3923
4871
|
}
|
|
3924
4872
|
|
|
3925
4873
|
# Commands that do not require the visual indicator / input blocker
|
|
3926
4874
|
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
|
|
3927
4875
|
'dialog', 'download', 'throttle', 'permission', 'intercept',
|
|
3928
|
-
'batch', 'screenshot-diff'
|
|
4876
|
+
'batch', 'screenshot-diff', 'run',
|
|
4877
|
+
'captcha-check', 'captcha-wait'}
|
|
3929
4878
|
# Clean up idle sessions before running any command
|
|
3930
4879
|
_cleanup_idle_sessions()
|
|
3931
4880
|
|