Skip to content

Commit 24fe0c5

Browse files
author
GitLab CI
committed
feat: add fill_rich_text, paste_text, solve_captcha tools + IIFE auto-wrap + Shadow DOM traversal
- _evalJs auto-wraps expressions containing const/let/class/function in IIFE to prevent redeclaration errors across multiple eval calls - getInteractiveElements + getInteractiveElementsStructured now recursively traverse Shadow DOM roots and include contenteditable elements - fill_rich_text: fills rich text editors (Draft.js, ProseMirror, Tiptap, Medium, Quill) with HTML/text injection + framework event dispatching - paste_text: instant text input via Input.insertText (clipboard simulation) - orders of magnitude faster than character-by-character typeText - solve_captcha: auto-detect and solve reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile, image CAPTCHAs via 2Captcha API
1 parent 4ab62ca commit 24fe0c5

File tree

4 files changed

+382
-6
lines changed

4 files changed

+382
-6
lines changed

lib/src/bridge/cdp_driver.dart

Lines changed: 277 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,16 @@ class CdpDriver implements AppDriver {
297297
{bool includePositions = true}) async {
298298
final result = await _evalJs('''
299299
(() => {
300-
const selectors = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [onclick], [tabindex]';
301-
const elements = Array.from(document.querySelectorAll(selectors));
300+
const selectors = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [onclick], [tabindex], [contenteditable="true"]';
301+
// Recursive query that traverses Shadow DOM
302+
function deepQueryAll(root, sel) {
303+
const results = Array.from(root.querySelectorAll(sel));
304+
root.querySelectorAll('*').forEach(el => {
305+
if (el.shadowRoot) results.push(...deepQueryAll(el.shadowRoot, sel));
306+
});
307+
return results;
308+
}
309+
const elements = deepQueryAll(document, selectors);
302310
return elements.filter(el => {
303311
const style = window.getComputedStyle(el);
304312
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
@@ -397,8 +405,15 @@ class CdpDriver implements AppDriver {
397405
Future<Map<String, dynamic>> getInteractiveElementsStructured() async {
398406
final result = await _evalJs('''
399407
(() => {
400-
const selectors = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [onclick], [tabindex]';
401-
const elements = Array.from(document.querySelectorAll(selectors));
408+
const selectors = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [onclick], [tabindex], [contenteditable="true"]';
409+
function deepQueryAll(root, sel) {
410+
const results = Array.from(root.querySelectorAll(sel));
411+
root.querySelectorAll('*').forEach(el => {
412+
if (el.shadowRoot) results.push(...deepQueryAll(el.shadowRoot, sel));
413+
});
414+
return results;
415+
}
416+
const elements = deepQueryAll(document, selectors);
402417
const visible = elements.filter(el => {
403418
const style = window.getComputedStyle(el);
404419
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
@@ -1125,6 +1140,254 @@ class CdpDriver implements AppDriver {
11251140
return jsonDecode(v) as Map<String, dynamic>;
11261141
}
11271142

1143+
/// Paste text instantly via CDP Input.insertText (clipboard-style).
1144+
/// Much faster than typeText for long content.
1145+
Future<void> pasteText(String text) async {
1146+
await _call('Input.insertText', {'text': text});
1147+
}
1148+
1149+
/// Fill a rich text editor (contenteditable, Draft.js, ProseMirror, Tiptap, Medium, etc.).
1150+
/// Finds the editor element, focuses it, clears content, and injects HTML or plain text.
1151+
/// [selector] - CSS selector for the editor element (e.g. '[contenteditable="true"]', '.ProseMirror', '.tiptap')
1152+
/// [html] - HTML content to inject (preferred for rich editors)
1153+
/// [text] - Plain text to inject (fallback)
1154+
/// [append] - If true, append instead of replacing content
1155+
Future<Map<String, dynamic>> fillRichText({
1156+
String? selector,
1157+
String? html,
1158+
String? text,
1159+
bool append = false,
1160+
}) async {
1161+
final content = html ?? text ?? '';
1162+
final isHtml = html != null;
1163+
final sel = selector ?? '[contenteditable="true"]';
1164+
final escapedContent =
1165+
content.replaceAll('\\', '\\\\').replaceAll('`', '\\`').replaceAll('\$', '\\\$');
1166+
1167+
final result = await _evalJs('''
1168+
(() => {
1169+
// Try multiple selectors for common rich text editors
1170+
const selectors = ['$sel', '.ProseMirror', '.tiptap', '[contenteditable="true"]', '.ql-editor', '.DraftEditor-root [contenteditable="true"]', '.graf--p'];
1171+
let el = null;
1172+
for (const s of selectors) {
1173+
el = document.querySelector(s);
1174+
if (el) break;
1175+
}
1176+
if (!el) return JSON.stringify({success: false, message: 'Rich text editor not found', triedSelectors: selectors});
1177+
1178+
el.focus();
1179+
1180+
if (!${append}) {
1181+
el.innerHTML = '';
1182+
}
1183+
1184+
if (${isHtml}) {
1185+
el.innerHTML ${append ? '+' : ''}= `$escapedContent`;
1186+
} else {
1187+
el.innerText ${append ? '+' : ''}= `$escapedContent`;
1188+
}
1189+
1190+
// Dispatch events for framework detection (React, Vue, Draft.js, Tiptap, ProseMirror)
1191+
el.dispatchEvent(new Event('input', {bubbles: true}));
1192+
el.dispatchEvent(new Event('change', {bubbles: true}));
1193+
// For ProseMirror/Tiptap — trigger a DOM mutation so the framework picks up changes
1194+
el.dispatchEvent(new Event('keyup', {bubbles: true}));
1195+
// For Draft.js — trigger beforeinput
1196+
try { el.dispatchEvent(new InputEvent('beforeinput', {bubbles: true, inputType: 'insertText'})); } catch(e) {}
1197+
1198+
return JSON.stringify({
1199+
success: true,
1200+
editor: el.className || el.tagName,
1201+
contentLength: el.innerHTML.length,
1202+
selector: '$sel'
1203+
});
1204+
})()
1205+
''');
1206+
final v = result['result']?['value'] as String?;
1207+
if (v == null) return {"success": false, "message": "Eval returned null"};
1208+
return jsonDecode(v) as Map<String, dynamic>;
1209+
}
1210+
1211+
/// Solve CAPTCHA using 2Captcha/Anti-Captcha service.
1212+
/// Supports reCAPTCHA v2/v3, hCaptcha, image CAPTCHA.
1213+
/// [apiKey] - API key for the CAPTCHA solving service
1214+
/// [service] - 'twocaptcha' or 'anticaptcha' (default: twocaptcha)
1215+
/// [siteKey] - reCAPTCHA/hCaptcha site key (auto-detected if not provided)
1216+
/// [pageUrl] - URL of the page (auto-detected if not provided)
1217+
/// [type] - 'recaptcha_v2', 'recaptcha_v3', 'hcaptcha', 'image' (auto-detected)
1218+
Future<Map<String, dynamic>> solveCaptcha({
1219+
required String apiKey,
1220+
String service = 'twocaptcha',
1221+
String? siteKey,
1222+
String? pageUrl,
1223+
String? type,
1224+
}) async {
1225+
// Step 1: Auto-detect CAPTCHA type and site key
1226+
final detection = await _evalJs('''
1227+
(() => {
1228+
const url = window.location.href;
1229+
// reCAPTCHA v2/v3
1230+
const recaptchaEl = document.querySelector('.g-recaptcha, [data-sitekey], iframe[src*="recaptcha"]');
1231+
if (recaptchaEl) {
1232+
const sk = recaptchaEl.getAttribute('data-sitekey') ||
1233+
(recaptchaEl.src ? new URL(recaptchaEl.src).searchParams.get('k') : null);
1234+
const isV3 = recaptchaEl.getAttribute('data-size') === 'invisible' || document.querySelector('script[src*="recaptcha/api.js?render="]') !== null;
1235+
return JSON.stringify({type: isV3 ? 'recaptcha_v3' : 'recaptcha_v2', siteKey: sk, pageUrl: url});
1236+
}
1237+
// hCaptcha
1238+
const hcaptchaEl = document.querySelector('.h-captcha, [data-sitekey][data-hcaptcha], iframe[src*="hcaptcha"]');
1239+
if (hcaptchaEl) {
1240+
const sk = hcaptchaEl.getAttribute('data-sitekey');
1241+
return JSON.stringify({type: 'hcaptcha', siteKey: sk, pageUrl: url});
1242+
}
1243+
// Cloudflare Turnstile
1244+
const turnstile = document.querySelector('.cf-turnstile, [data-sitekey]');
1245+
if (turnstile && turnstile.classList.contains('cf-turnstile')) {
1246+
return JSON.stringify({type: 'turnstile', siteKey: turnstile.getAttribute('data-sitekey'), pageUrl: url});
1247+
}
1248+
// Image CAPTCHA
1249+
const imgCaptcha = document.querySelector('img[src*="captcha"], img[alt*="captcha"], img[class*="captcha"]');
1250+
if (imgCaptcha) {
1251+
return JSON.stringify({type: 'image', imgSrc: imgCaptcha.src, pageUrl: url});
1252+
}
1253+
return JSON.stringify({type: 'none', pageUrl: url});
1254+
})()
1255+
''');
1256+
1257+
final detectionValue = detection['result']?['value'] as String?;
1258+
if (detectionValue == null) {
1259+
return {"success": false, "message": "Failed to detect CAPTCHA"};
1260+
}
1261+
final detected = jsonDecode(detectionValue) as Map<String, dynamic>;
1262+
final captchaType = type ?? detected['type'] as String?;
1263+
final detectedSiteKey = siteKey ?? detected['siteKey'] as String?;
1264+
final detectedPageUrl = pageUrl ?? detected['pageUrl'] as String?;
1265+
1266+
if (captchaType == 'none') {
1267+
return {"success": true, "message": "No CAPTCHA detected on page"};
1268+
}
1269+
1270+
// Step 2: Submit to solving service
1271+
final http.Client httpClient = http.Client();
1272+
try {
1273+
String taskId;
1274+
1275+
if (service == 'twocaptcha') {
1276+
// 2Captcha API
1277+
final submitUrl = Uri.parse('http://2captcha.com/in.php');
1278+
final params = <String, String>{
1279+
'key': apiKey,
1280+
'json': '1',
1281+
};
1282+
1283+
if (captchaType == 'recaptcha_v2' || captchaType == 'recaptcha_v3') {
1284+
params['method'] = 'userrecaptcha';
1285+
params['googlekey'] = detectedSiteKey ?? '';
1286+
params['pageurl'] = detectedPageUrl ?? '';
1287+
if (captchaType == 'recaptcha_v3') {
1288+
params['version'] = 'v3';
1289+
params['action'] = 'verify';
1290+
params['min_score'] = '0.3';
1291+
}
1292+
} else if (captchaType == 'hcaptcha') {
1293+
params['method'] = 'hcaptcha';
1294+
params['sitekey'] = detectedSiteKey ?? '';
1295+
params['pageurl'] = detectedPageUrl ?? '';
1296+
} else if (captchaType == 'turnstile') {
1297+
params['method'] = 'turnstile';
1298+
params['sitekey'] = detectedSiteKey ?? '';
1299+
params['pageurl'] = detectedPageUrl ?? '';
1300+
} else if (captchaType == 'image') {
1301+
// For image CAPTCHA, download and send base64
1302+
final imgSrc = detected['imgSrc'] as String?;
1303+
if (imgSrc == null) return {"success": false, "message": "No CAPTCHA image found"};
1304+
final imgResponse = await httpClient.get(Uri.parse(imgSrc));
1305+
params['method'] = 'base64';
1306+
params['body'] = base64Encode(imgResponse.bodyBytes);
1307+
}
1308+
1309+
final response = await httpClient.post(submitUrl, body: params);
1310+
final submitResult = jsonDecode(response.body) as Map<String, dynamic>;
1311+
if (submitResult['status'] != 1) {
1312+
return {"success": false, "message": "Submit failed: ${submitResult['request']}"};
1313+
}
1314+
taskId = submitResult['request'] as String;
1315+
1316+
// Step 3: Poll for result
1317+
for (int i = 0; i < 60; i++) {
1318+
await Future.delayed(const Duration(seconds: 5));
1319+
final pollUrl = Uri.parse('http://2captcha.com/res.php?key=$apiKey&action=get&id=$taskId&json=1');
1320+
final pollResponse = await httpClient.get(pollUrl);
1321+
final pollResult = jsonDecode(pollResponse.body) as Map<String, dynamic>;
1322+
if (pollResult['status'] == 1) {
1323+
final token = pollResult['request'] as String;
1324+
1325+
// Step 4: Inject solution
1326+
if (captchaType == 'image') {
1327+
// For image CAPTCHA, fill the input field
1328+
await _evalJs('''
1329+
(() => {
1330+
const input = document.querySelector('input[name*="captcha"], input[id*="captcha"], input[class*="captcha"]');
1331+
if (input) { input.value = '$token'; input.dispatchEvent(new Event('input', {bubbles: true})); }
1332+
})()
1333+
''');
1334+
} else {
1335+
// For reCAPTCHA/hCaptcha/Turnstile — inject token into callback
1336+
await _evalJs('''
1337+
(() => {
1338+
const textarea = document.querySelector('#g-recaptcha-response, [name="g-recaptcha-response"], textarea[name="h-captcha-response"]');
1339+
if (textarea) {
1340+
textarea.style.display = '';
1341+
textarea.value = '$token';
1342+
textarea.dispatchEvent(new Event('input', {bubbles: true}));
1343+
}
1344+
// Call callback if available
1345+
if (typeof ___grecaptcha_cfg !== 'undefined') {
1346+
const clients = ___grecaptcha_cfg.clients;
1347+
if (clients) {
1348+
Object.keys(clients).forEach(k => {
1349+
const c = clients[k];
1350+
// Find callback in nested structure
1351+
const findCb = (obj) => {
1352+
if (!obj || typeof obj !== 'object') return null;
1353+
for (const key of Object.keys(obj)) {
1354+
if (typeof obj[key] === 'function') return obj[key];
1355+
const found = findCb(obj[key]);
1356+
if (found) return found;
1357+
}
1358+
return null;
1359+
};
1360+
const cb = findCb(c);
1361+
if (cb) cb('$token');
1362+
});
1363+
}
1364+
}
1365+
// hCaptcha callback
1366+
if (window.hcaptcha) window.hcaptcha.execute();
1367+
})()
1368+
''');
1369+
}
1370+
1371+
return {
1372+
"success": true,
1373+
"type": captchaType,
1374+
"token": token.length > 50 ? '${token.substring(0, 50)}...' : token,
1375+
"message": "CAPTCHA solved and injected"
1376+
};
1377+
}
1378+
if (pollResult['request'] != 'CAPCHA_NOT_READY') {
1379+
return {"success": false, "message": "Solve failed: ${pollResult['request']}"};
1380+
}
1381+
}
1382+
return {"success": false, "message": "Timeout waiting for CAPTCHA solution"};
1383+
} else {
1384+
return {"success": false, "message": "Service '$service' not supported yet. Use 'twocaptcha'."};
1385+
}
1386+
} finally {
1387+
httpClient.close();
1388+
}
1389+
}
1390+
11281391
/// Highlight an element on the page.
11291392
Future<Map<String, dynamic>> highlightElement(String selector,
11301393
{String color = 'red', int duration = 3000}) async {
@@ -1568,8 +1831,17 @@ function deepQueryAll(selector, root) {
15681831
''';
15691832

15701833
Future<Map<String, dynamic>> _evalJs(String expression) async {
1834+
// Auto-wrap in IIFE to avoid 'const' redeclaration errors across calls.
1835+
// Skip if already wrapped or is a simple expression (no declarations).
1836+
final trimmed = expression.trim();
1837+
final needsWrap = !trimmed.startsWith('(') &&
1838+
(trimmed.contains('const ') ||
1839+
trimmed.contains('let ') ||
1840+
trimmed.contains('class ') ||
1841+
trimmed.contains('function '));
1842+
final wrapped = needsWrap ? '(() => { $trimmed })()' : expression;
15711843
return _call('Runtime.evaluate', {
1572-
'expression': expression,
1844+
'expression': wrapped,
15731845
'returnByValue': true,
15741846
'awaitPromise': false,
15751847
});

lib/src/cli/tool_handlers/cdp_tool_handlers.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,29 @@ extension _CdpToolHandlers on FlutterMcpServer {
394394
await cdp.typeText(text);
395395
return {"success": true, "text": text};
396396

397+
case 'paste_text':
398+
final text = args['text'] as String? ?? '';
399+
await cdp.pasteText(text);
400+
return {"success": true, "length": text.length};
401+
402+
case 'fill_rich_text':
403+
return await cdp.fillRichText(
404+
selector: args['selector'] as String?,
405+
html: args['html'] as String?,
406+
text: args['text'] as String?,
407+
append: args['append'] == true,
408+
);
409+
410+
case 'solve_captcha':
411+
final apiKey = args['api_key'] as String? ?? '';
412+
if (apiKey.isEmpty) return {"success": false, "message": "api_key is required"};
413+
return await cdp.solveCaptcha(
414+
apiKey: apiKey,
415+
siteKey: args['site_key'] as String?,
416+
pageUrl: args['page_url'] as String?,
417+
type: args['type'] as String?,
418+
);
419+
397420
case 'hover':
398421
return await cdp.hover(
399422
key: args['key'], text: args['text'], ref: args['ref']);

0 commit comments

Comments
 (0)