docs: runnable playwright + lightpanda examples

This commit is contained in:
2026-06-07 13:53:42 +01:00
parent 5987113a64
commit 7b38b9198f
3 changed files with 324 additions and 0 deletions
+146
View File
@@ -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();