mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-14 00:57:55 +00:00
test(e2e): playwright browser harness against 2captcha demo
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// RELIABLE browser e2e: solve the LIVE 2captcha text-captcha demo end-to-end.
|
||||
//
|
||||
// server (brain) ── solve_text_captcha ──┐
|
||||
// Playwright (hands) ── screenshot/type/submit ──> 2captcha demo verifies the answer
|
||||
//
|
||||
// The 2captcha demo at https://2captcha.com/demo/normal is the ground truth: it renders a
|
||||
// distorted-text image, accepts a typed answer, and shows a success message only when the
|
||||
// answer is correct. We screenshot the image element, ask the MCP server's vision OCR to
|
||||
// read it, type the prediction, click "Check", and assert the demo's own success indicator.
|
||||
//
|
||||
// AUTHORIZED USE ONLY — this targets 2captcha's public, self-verifying demo page.
|
||||
//
|
||||
// Required env (passed straight through to the spawned server):
|
||||
// CAPTCHA_LLM_PROVIDER=openai-compatible
|
||||
// CAPTCHA_LLM_BASE_URL=https://llmgw.h.raczylo.com
|
||||
// CAPTCHA_LLM_MODEL=openai/gpt-5.4
|
||||
// CAPTCHA_LLM_TIMEOUT=120s (optional; defaults to 120s here)
|
||||
// CAPTCHA_LLM_API_KEY=... (optional; gateway is keyless)
|
||||
// CAPTCHA_BIN=/abs/path (optional; defaults to repo bin/captcha-solver-mcp)
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { McpStdioClient } from '../../../examples/mcp-client.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
||||
|
||||
const TARGET_URL = 'https://2captcha.com/demo/normal';
|
||||
const CAPTCHA_BIN = process.env.CAPTCHA_BIN || path.join(REPO_ROOT, 'bin', 'captcha-solver-mcp');
|
||||
const HEADLESS = process.env.CAPTCHA_E2E_HEADLESS !== '0';
|
||||
const MAX_ATTEMPTS = 3; // initial + up to 2 fresh retries; OCR is stochastic.
|
||||
|
||||
// Selectors verified against the live DOM (CSS-module hashes are partial-matched).
|
||||
const SEL = {
|
||||
image: 'img[class*="_captchaImage"]',
|
||||
input: '#simple-captcha-field',
|
||||
submit: 'form[class*="_widgetForm"] button[type="submit"]',
|
||||
success: '[class*="_successMessage"]',
|
||||
error: '[class*="_errorMessage"], [class*="_alertError"]',
|
||||
};
|
||||
|
||||
const serverEnv = {
|
||||
CAPTCHA_LLM_PROVIDER: process.env.CAPTCHA_LLM_PROVIDER || 'openai-compatible',
|
||||
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,
|
||||
CAPTCHA_LLM_TIMEOUT: process.env.CAPTCHA_LLM_TIMEOUT || '120s',
|
||||
};
|
||||
|
||||
function log(...args) {
|
||||
console.log('[solve-text]', ...args);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mcp = new McpStdioClient(CAPTCHA_BIN, serverEnv);
|
||||
await mcp.initialize();
|
||||
|
||||
// Stealth init script the page never sees as automation.
|
||||
const stealth = await mcp.callTool('get_stealth_script', { engine: 'playwright' });
|
||||
log('stealth evasions:', stealth.included_evasions);
|
||||
|
||||
const browser = await chromium.launch({ headless: HEADLESS });
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript({ content: stealth.script });
|
||||
const page = await context.newPage();
|
||||
|
||||
let passed = false;
|
||||
const predictions = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
// Fresh load each attempt so any per-load captcha state resets.
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
const img = page.locator(SEL.image).first();
|
||||
await img.waitFor({ state: 'visible', timeout: 30000 });
|
||||
|
||||
// Screenshot just the image element -> base64 PNG for the OCR tool.
|
||||
const pngBuf = await img.screenshot({ type: 'png' });
|
||||
const dataUri = `data:image/png;base64,${pngBuf.toString('base64')}`;
|
||||
|
||||
// The demo's normal captcha is a 5-char alphanumeric, case-insensitive.
|
||||
const solved = await mcp.callTool('solve_text_captcha', {
|
||||
image: dataUri,
|
||||
length: 5,
|
||||
case_sensitive: false,
|
||||
});
|
||||
const prediction = (solved.text || '').trim();
|
||||
predictions.push(prediction);
|
||||
log(`attempt ${attempt}/${MAX_ATTEMPTS}: predicted="${prediction}" confidence=${solved.confidence}`);
|
||||
|
||||
if (!prediction) {
|
||||
log(`attempt ${attempt}: empty prediction; retrying`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = page.locator(SEL.input);
|
||||
await input.fill('');
|
||||
await input.fill(prediction);
|
||||
await page.locator(SEL.submit).click();
|
||||
|
||||
// Wait for either the success or the error indicator to appear.
|
||||
const outcome = await Promise.race([
|
||||
page
|
||||
.locator(SEL.success)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 })
|
||||
.then(() => 'success')
|
||||
.catch(() => null),
|
||||
page
|
||||
.locator(SEL.error)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 })
|
||||
.then(() => 'error')
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
if (outcome === 'success') {
|
||||
const msg = (await page.locator(SEL.success).first().innerText()).trim();
|
||||
log(`attempt ${attempt}: SUCCESS — demo says: "${msg}"`);
|
||||
passed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const errText = (await page.locator(SEL.error).first().innerText().catch(() => '')).trim();
|
||||
log(`attempt ${attempt}: NOT accepted (demo: "${errText || 'no success indicator'}")`);
|
||||
}
|
||||
} finally {
|
||||
mcp.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (!passed) {
|
||||
console.error(
|
||||
`[solve-text] FAIL: demo did not accept any prediction after ${MAX_ATTEMPTS} attempts. ` +
|
||||
`predictions=${JSON.stringify(predictions)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log(`PASS: predictions tried=${JSON.stringify(predictions)} — demo confirmed correct.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[solve-text] ERROR:', err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user