diff --git a/examples/lightpanda/detect.mjs b/examples/lightpanda/detect.mjs new file mode 100644 index 0000000..d8ee5bc --- /dev/null +++ b/examples/lightpanda/detect.mjs @@ -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 '); + 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(); diff --git a/examples/mcp-client.mjs b/examples/mcp-client.mjs new file mode 100644 index 0000000..5951101 --- /dev/null +++ b/examples/mcp-client.mjs @@ -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} 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(); + } +} diff --git a/examples/playwright/solve-recaptcha.mjs b/examples/playwright/solve-recaptcha.mjs new file mode 100644 index 0000000..17aeb53 --- /dev/null +++ b/examples/playwright/solve-recaptcha.mjs @@ -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 '); + 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();