mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
docs: runnable playwright + lightpanda examples
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Reference: detect captchas on a page using Lightpanda (a lightweight headless browser)
|
||||
// as the "hands" and captcha-solver-mcp's deterministic `detect_captcha` as the "brain".
|
||||
//
|
||||
// `detect_captcha` is a pure, deterministic HTML scan — it needs no LLM call to classify
|
||||
// known providers. It flags reCAPTCHA v3 and Cloudflare Turnstile as `llm_solvable:false`
|
||||
// because those are behavioral / proof-of-work challenges, not vision puzzles.
|
||||
//
|
||||
// This example shows the simplest possible integration: fetch the page DOM with Lightpanda,
|
||||
// hand the HTML to the server, and print what was found. No clicking, no tokens.
|
||||
//
|
||||
// Run it yourself (this repo does not vendor node_modules):
|
||||
// npm i playwright # used here purely as a CDP client to talk to Lightpanda
|
||||
// lightpanda serve --host 127.0.0.1 --port 9222 # start Lightpanda's CDP endpoint
|
||||
// node examples/lightpanda/detect.mjs https://your-own-test-page.example
|
||||
//
|
||||
// Required env:
|
||||
// CAPTCHA_BIN=/abs/path/to/bin/captcha-solver-mcp
|
||||
// CAPTCHA_LLM_PROVIDER=anthropic # any valid provider; detection won't call it
|
||||
// CAPTCHA_LLM_API_KEY=sk-...
|
||||
// CAPTCHA_LLM_MODEL=claude-opus-4-8
|
||||
// LIGHTPANDA_CDP=ws://127.0.0.1:9222 # Lightpanda CDP websocket (optional override)
|
||||
//
|
||||
// AUTHORIZED USE ONLY. Detect captchas on pages you own or are permitted to test.
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { McpStdioClient } from '../mcp-client.mjs';
|
||||
|
||||
const TARGET_URL = process.argv[2];
|
||||
const CAPTCHA_BIN = process.env.CAPTCHA_BIN || '../../bin/captcha-solver-mcp';
|
||||
const LIGHTPANDA_CDP = process.env.LIGHTPANDA_CDP || 'ws://127.0.0.1:9222';
|
||||
|
||||
if (!TARGET_URL) {
|
||||
console.error('usage: node detect.mjs <url-of-your-own-test-page>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 1. Start the brain. detect_captcha is deterministic, but the server still validates its
|
||||
// LLM config at startup, so the CAPTCHA_LLM_* vars must be present and valid.
|
||||
const mcp = new McpStdioClient(CAPTCHA_BIN, {
|
||||
CAPTCHA_LLM_PROVIDER: process.env.CAPTCHA_LLM_PROVIDER,
|
||||
CAPTCHA_LLM_BASE_URL: process.env.CAPTCHA_LLM_BASE_URL,
|
||||
CAPTCHA_LLM_API_KEY: process.env.CAPTCHA_LLM_API_KEY,
|
||||
CAPTCHA_LLM_MODEL: process.env.CAPTCHA_LLM_MODEL,
|
||||
});
|
||||
await mcp.initialize();
|
||||
|
||||
// 2. Connect to Lightpanda over CDP and fetch the rendered DOM.
|
||||
// (Lightpanda speaks the Chrome DevTools Protocol, so any CDP client works; we reuse
|
||||
// Playwright's connectOverCDP here to keep the example dependency-light.)
|
||||
const browser = await chromium.connectOverCDP(LIGHTPANDA_CDP);
|
||||
const context = browser.contexts()[0] || (await browser.newContext());
|
||||
const page = context.pages()[0] || (await context.newPage());
|
||||
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
const html = await page.content(); // full serialized DOM, including captcha iframes/markers
|
||||
|
||||
// 3. Ask the brain to classify. Pure HTML scan — no LLM round-trip.
|
||||
const result = await mcp.callTool('detect_captcha', { url: TARGET_URL, html });
|
||||
|
||||
console.log(result.summary);
|
||||
for (const d of result.detected) {
|
||||
const verdict = d.llm_solvable ? 'LLM-solvable' : 'DETECT-ONLY (behavioral/PoW)';
|
||||
console.log(`- ${d.type}${d.sitekey ? ` sitekey=${d.sitekey}` : ''} -> ${verdict}`);
|
||||
if (d.notes) console.log(` notes: ${d.notes}`);
|
||||
}
|
||||
|
||||
if (result.detected.length === 0) {
|
||||
console.log('no known captcha provider found in the DOM');
|
||||
}
|
||||
|
||||
mcp.close();
|
||||
await browser.close();
|
||||
@@ -0,0 +1,106 @@
|
||||
// Minimal newline-delimited JSON-RPC client for the captcha-solver-mcp stdio server.
|
||||
//
|
||||
// This is intentionally tiny and dependency-free so the examples are runnable with just
|
||||
// Node and the built `bin/captcha-solver-mcp`. For production use you can swap this for
|
||||
// the official `@modelcontextprotocol/sdk` (StdioClientTransport) instead.
|
||||
//
|
||||
// The MCP stdio transport frames each JSON-RPC message as a single line of JSON terminated
|
||||
// by '\n'. We spawn the server, perform the `initialize` handshake, then expose `callTool`.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export class McpStdioClient {
|
||||
/**
|
||||
* @param {string} command Absolute path to the bin/captcha-solver-mcp binary.
|
||||
* @param {Record<string,string>} env CAPTCHA_LLM_* configuration for the server.
|
||||
*/
|
||||
constructor(command, env = {}) {
|
||||
this.proc = spawn(command, [], {
|
||||
stdio: ['pipe', 'pipe', 'inherit'], // server logs go to our stderr
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
this.nextId = 1;
|
||||
this.pending = new Map(); // id -> {resolve, reject}
|
||||
this.buffer = '';
|
||||
|
||||
this.proc.stdout.on('data', (chunk) => this.#onData(chunk));
|
||||
this.proc.on('exit', (code) => {
|
||||
for (const { reject } of this.pending.values()) {
|
||||
reject(new Error(`server exited with code ${code}`));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
#onData(chunk) {
|
||||
this.buffer += chunk.toString('utf8');
|
||||
let nl;
|
||||
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
||||
const line = this.buffer.slice(0, nl).trim();
|
||||
this.buffer = this.buffer.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // ignore non-JSON noise
|
||||
}
|
||||
if (msg.id == null) continue; // notifications: ignore
|
||||
const waiter = this.pending.get(msg.id);
|
||||
if (!waiter) continue;
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.error) waiter.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
|
||||
else waiter.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
|
||||
#request(method, params) {
|
||||
const id = this.nextId++;
|
||||
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.proc.stdin.write(payload);
|
||||
});
|
||||
}
|
||||
|
||||
#notify(method, params) {
|
||||
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
||||
}
|
||||
|
||||
/** Perform the MCP initialize handshake. Call once before callTool. */
|
||||
async initialize() {
|
||||
const result = await this.#request('initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'captcha-example', version: '0.1.0' },
|
||||
});
|
||||
this.#notify('notifications/initialized', {});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool by name. Returns the tool's structured output (structuredContent),
|
||||
* falling back to the parsed text content if no structured output is present.
|
||||
* @param {string} name
|
||||
* @param {object} args
|
||||
*/
|
||||
async callTool(name, args) {
|
||||
const result = await this.#request('tools/call', { name, arguments: args });
|
||||
if (result.isError) {
|
||||
const text = (result.content || []).map((c) => c.text).join('\n');
|
||||
throw new Error(`tool ${name} failed: ${text}`);
|
||||
}
|
||||
if (result.structuredContent) return result.structuredContent;
|
||||
const text = (result.content || []).map((c) => c.text).join('');
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.proc.stdin.end();
|
||||
this.proc.kill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Reference: solve a reCAPTCHA v2 image-grid challenge with Playwright as the "hands"
|
||||
// and captcha-solver-mcp as the "brain".
|
||||
//
|
||||
// The MCP server NEVER drives the browser and NEVER mints a token. Playwright does the
|
||||
// navigation, screenshots, clicks, and token harvesting; the server only tells us which
|
||||
// tiles match and produces stealth artifacts + a humanized click timeline.
|
||||
//
|
||||
// Run it yourself (this repo does not vendor node_modules):
|
||||
// npm i playwright
|
||||
// node examples/playwright/solve-recaptcha.mjs https://your-own-test-page.example/recaptcha
|
||||
//
|
||||
// Required env (point at your own vision-capable LLM endpoint):
|
||||
// CAPTCHA_BIN=/abs/path/to/bin/captcha-solver-mcp
|
||||
// CAPTCHA_LLM_PROVIDER=openai-compatible
|
||||
// CAPTCHA_LLM_BASE_URL=https://your-gateway.example.com/v1
|
||||
// CAPTCHA_LLM_API_KEY=sk-...
|
||||
// CAPTCHA_LLM_MODEL=openai/gpt-5.4
|
||||
//
|
||||
// AUTHORIZED USE ONLY. Run this against captcha integrations you own or are permitted to
|
||||
// test. Do not use it to evade protections on sites you do not control.
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { McpStdioClient } from '../mcp-client.mjs';
|
||||
|
||||
const TARGET_URL = process.argv[2];
|
||||
const CAPTCHA_BIN = process.env.CAPTCHA_BIN || '../../bin/captcha-solver-mcp';
|
||||
const MAX_ROUNDS = 6; // dynamic grids re-render; cap the loop
|
||||
|
||||
if (!TARGET_URL) {
|
||||
console.error('usage: node solve-recaptcha.mjs <url-of-your-own-recaptcha-test-page>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// LLM config is passed straight through to the server process.
|
||||
const serverEnv = {
|
||||
CAPTCHA_LLM_PROVIDER: process.env.CAPTCHA_LLM_PROVIDER,
|
||||
CAPTCHA_LLM_BASE_URL: process.env.CAPTCHA_LLM_BASE_URL,
|
||||
CAPTCHA_LLM_API_KEY: process.env.CAPTCHA_LLM_API_KEY,
|
||||
CAPTCHA_LLM_MODEL: process.env.CAPTCHA_LLM_MODEL,
|
||||
};
|
||||
|
||||
const mcp = new McpStdioClient(CAPTCHA_BIN, serverEnv);
|
||||
await mcp.initialize();
|
||||
|
||||
// 1. Prep identity: coherent fingerprint + matching headers, and the evasion init script.
|
||||
// A stable `seed` keeps the identity consistent across runs.
|
||||
const fp = await mcp.callTool('generate_fingerprint', {
|
||||
os: 'macos',
|
||||
browser: 'chrome',
|
||||
locale: 'en-US',
|
||||
seed: 42,
|
||||
});
|
||||
const stealth = await mcp.callTool('get_stealth_script', { engine: 'playwright' });
|
||||
console.log('stealth evasions:', stealth.included_evasions);
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
userAgent: fp.header_set['User-Agent'] || fp.header_set['user-agent'],
|
||||
locale: 'en-US',
|
||||
extraHTTPHeaders: fp.header_set, // apply the coherent header set
|
||||
});
|
||||
// Inject the evasion script BEFORE any page script runs.
|
||||
await context.addInitScript({ content: stealth.script });
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 2. Trigger the challenge. Selectors here are illustrative — adapt to your test page.
|
||||
// Typically: click the "I'm not a robot" checkbox, then wait for the tile iframe.
|
||||
await page.frameLocator('iframe[title="reCAPTCHA"]').locator('#recaptcha-anchor').click();
|
||||
|
||||
// The image challenge lives in a second iframe ("recaptcha challenge expires...").
|
||||
const challenge = page.frameLocator('iframe[title*="challenge"]');
|
||||
|
||||
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||
// Wait for the grid + instruction to be present.
|
||||
const instruction = (await challenge.locator('.rc-imageselect-instructions').innerText())
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const gridTable = challenge.locator('table');
|
||||
const box = await gridTable.boundingBox();
|
||||
if (!box) throw new Error('could not locate the grid table');
|
||||
|
||||
// Infer grid size from the rendered cells (3x3 or 4x4 are the common shapes).
|
||||
const cells = await challenge.locator('table td').count();
|
||||
const cols = Math.round(Math.sqrt(cells));
|
||||
const rows = Math.round(cells / cols);
|
||||
|
||||
// 2b. Screenshot just the grid so pixel coordinates line up with the brain's centroids.
|
||||
const png = await gridTable.screenshot();
|
||||
const screenshot = `data:image/png;base64,${png.toString('base64')}`;
|
||||
|
||||
// 3. Ask the brain which tiles to click. Pass image dims to get pixel centroids back.
|
||||
const grid = await mcp.callTool('solve_grid_captcha', {
|
||||
screenshot,
|
||||
instruction,
|
||||
rows,
|
||||
cols,
|
||||
image_width: Math.round(box.width),
|
||||
image_height: Math.round(box.height),
|
||||
captcha_type: 'recaptcha',
|
||||
});
|
||||
console.log(`round ${round}: instruction=${instruction} tiles=${grid.tiles} conf=${grid.confidence}`);
|
||||
|
||||
if (!grid.tiles || grid.tiles.length === 0) break; // nothing matches -> done selecting
|
||||
|
||||
// 4. Humanize: turn centroids (page-absolute) into a Bézier mouse/click timeline.
|
||||
const clickActions = grid.tiles.map((tileIndex, i) => {
|
||||
// Prefer brain-provided centroids; otherwise derive from the grid geometry.
|
||||
const c = grid.centroids && grid.centroids[i];
|
||||
const localX = c ? c.x : (tileIndex % cols + 0.5) * (box.width / cols);
|
||||
const localY = c ? c.y : (Math.floor(tileIndex / cols) + 0.5) * (box.height / rows);
|
||||
return { type: 'click', x: Math.round(box.x + localX), y: Math.round(box.y + localY) };
|
||||
});
|
||||
const plan = await mcp.callTool('plan_interaction', { actions: clickActions, seed: 7 });
|
||||
|
||||
// Replay the humanized timeline against the real mouse.
|
||||
for (const step of plan.steps) {
|
||||
if (step.type === 'move') await page.mouse.move(step.x, step.y);
|
||||
else if (step.type === 'down') await page.mouse.down();
|
||||
else if (step.type === 'up') await page.mouse.up();
|
||||
// (typing steps are not used for grid clicks)
|
||||
}
|
||||
|
||||
// 6. If the grid is dynamic, the brain told us to re-check: loop and re-screenshot.
|
||||
if (grid.recheck_recommended) {
|
||||
await page.waitForTimeout(800); // let new tiles fade in
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. Verify.
|
||||
await challenge.locator('#recaptcha-verify-button').click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 7. Harvest the token. The caller (us) reads it — the server never sees it.
|
||||
const token = await page.evaluate(() => {
|
||||
const el = document.querySelector('textarea[name="g-recaptcha-response"]');
|
||||
return el ? el.value : '';
|
||||
});
|
||||
console.log('g-recaptcha-response token length:', token.length);
|
||||
if (!token) console.warn('no token yet — challenge may still be unsolved');
|
||||
|
||||
mcp.close();
|
||||
await browser.close();
|
||||
Reference in New Issue
Block a user