mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
test(e2e): playwright browser harness against 2captcha demo
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build test e2e e2e-live lint tidy
|
.PHONY: build test e2e e2e-live e2e-browser lint tidy
|
||||||
build:
|
build:
|
||||||
go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp
|
go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp
|
||||||
test:
|
test:
|
||||||
@@ -7,6 +7,15 @@ e2e:
|
|||||||
go test -tags e2e ./test/e2e/...
|
go test -tags e2e ./test/e2e/...
|
||||||
e2e-live:
|
e2e-live:
|
||||||
CAPTCHA_E2E_LIVE=1 go test -tags e2e,live ./test/e2e/...
|
CAPTCHA_E2E_LIVE=1 go test -tags e2e,live ./test/e2e/...
|
||||||
|
# Live browser e2e against the 2captcha demo (network + Chromium required).
|
||||||
|
# Prereqs: npm install && npx playwright install chromium
|
||||||
|
e2e-browser:
|
||||||
|
CAPTCHA_E2E_LIVE=1 \
|
||||||
|
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 \
|
||||||
|
go test -tags 'e2e live' -run Browser -v ./test/e2e/...
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
tidy:
|
tidy:
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//go:build e2e && live
|
||||||
|
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runBrowserScript shells out to a Playwright .mjs harness under test/e2e/playwright.
|
||||||
|
// It gates on CAPTCHA_E2E_LIVE=1, requires `node` and an installed Playwright Chromium,
|
||||||
|
// inherits the ambient CAPTCHA_LLM_* gateway config, and asserts a clean (exit 0) run.
|
||||||
|
func runBrowserScript(t *testing.T, script string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if os.Getenv("CAPTCHA_E2E_LIVE") != "1" {
|
||||||
|
t.Skip("CAPTCHA_E2E_LIVE != 1; skipping live browser e2e")
|
||||||
|
}
|
||||||
|
if os.Getenv("CAPTCHA_LLM_PROVIDER") == "" || os.Getenv("CAPTCHA_LLM_MODEL") == "" {
|
||||||
|
t.Skip("CAPTCHA_LLM_PROVIDER / CAPTCHA_LLM_MODEL not set; skipping live browser e2e")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("node"); err != nil {
|
||||||
|
t.Skip("node not found on PATH; skipping live browser e2e")
|
||||||
|
}
|
||||||
|
if !chromiumInstalled() {
|
||||||
|
t.Skip("Playwright Chromium not installed (run: npx playwright install chromium); skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwd is test/e2e so the script path "playwright/<script>" resolves and pnpm's
|
||||||
|
// node_modules above it is reachable for the `playwright` import.
|
||||||
|
cmd := exec.Command("node", filepath.Join("playwright", script))
|
||||||
|
cmd.Dir = "."
|
||||||
|
cmd.Env = append(cmd.Environ(),
|
||||||
|
"CAPTCHA_LLM_TIMEOUT="+envOr("CAPTCHA_LLM_TIMEOUT", "120s"),
|
||||||
|
)
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
|
||||||
|
// Generous ceiling: live network + a vision model + (recaptcha) several rounds.
|
||||||
|
timer := time.AfterFunc(5*time.Minute, func() {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("browser script %s failed: %v", script, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromiumInstalled reports whether a Playwright Chromium build is present in the
|
||||||
|
// default browsers cache. Playwright also honors PLAYWRIGHT_BROWSERS_PATH.
|
||||||
|
func chromiumInstalled() bool {
|
||||||
|
dirs := []string{}
|
||||||
|
if p := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); p != "" {
|
||||||
|
dirs = append(dirs, p)
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
dirs = append(dirs,
|
||||||
|
filepath.Join(home, "Library", "Caches", "ms-playwright"), // macOS
|
||||||
|
filepath.Join(home, ".cache", "ms-playwright"), // linux
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(d, "chromium-*"))
|
||||||
|
if len(matches) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserSolveText drives the RELIABLE path: the live 2captcha text-captcha demo,
|
||||||
|
// solved via the MCP server + a real vision model, verified by the demo's own success
|
||||||
|
// indicator. Fails if the demo does not accept any prediction after retries.
|
||||||
|
func TestBrowserSolveText(t *testing.T) {
|
||||||
|
runBrowserScript(t, "solve-text.mjs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrowserRecaptchaV2 drives the BEST-EFFORT path: the live 2captcha reCAPTCHA v2
|
||||||
|
// demo. The script returns 0 whether or not a token is obtained (Google often resists
|
||||||
|
// headless), failing only on a real script/transport error.
|
||||||
|
func TestBrowserRecaptchaV2(t *testing.T) {
|
||||||
|
runBrowserScript(t, "solve-recaptcha-v2.mjs")
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// BEST-EFFORT browser e2e: attempt the LIVE 2captcha reCAPTCHA v2 demo.
|
||||||
|
//
|
||||||
|
// server (brain) ── solve_grid_captcha / plan_interaction ──┐
|
||||||
|
// Playwright (hands) ── checkbox/screenshot/clicks/verify ──> reads g-recaptcha-response
|
||||||
|
//
|
||||||
|
// Target: https://2captcha.com/demo/recaptcha-v2 (real Google reCAPTCHA v2). Google
|
||||||
|
// aggressively resists headless/automation, so obtaining a token is NOT guaranteed. This
|
||||||
|
// script is BEST-EFFORT: it logs the outcome and EXITS 0 even with no token. It exits
|
||||||
|
// non-zero ONLY on a real script/transport error (e.g. server crash, navigation failure).
|
||||||
|
//
|
||||||
|
// AUTHORIZED USE ONLY — this targets 2captcha's public reCAPTCHA 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)
|
||||||
|
// 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/recaptcha-v2';
|
||||||
|
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_ROUNDS = 5; // dynamic grids re-render; cap the loop.
|
||||||
|
|
||||||
|
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('[recaptcha-v2]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centroids come back from the Go imageutil.Point struct, which has no JSON tags,
|
||||||
|
// so it serializes with capitalized keys {X,Y}. Tolerate both shapes.
|
||||||
|
function centroidXY(c) {
|
||||||
|
if (!c) return null;
|
||||||
|
const x = c.x ?? c.X;
|
||||||
|
const y = c.y ?? c.Y;
|
||||||
|
if (typeof x !== 'number' || typeof y !== 'number') return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readToken(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const el = document.querySelector('textarea[name="g-recaptcha-response"]');
|
||||||
|
return el ? el.value : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mcp = new McpStdioClient(CAPTCHA_BIN, serverEnv);
|
||||||
|
await mcp.initialize();
|
||||||
|
|
||||||
|
// Coherent identity + matching headers + evasion init script.
|
||||||
|
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' });
|
||||||
|
log('stealth evasions:', stealth.included_evasions);
|
||||||
|
|
||||||
|
const headers = (fp.header_set && fp.header_set.headers) || {};
|
||||||
|
const userAgent = headers['User-Agent'] || headers['user-agent'];
|
||||||
|
const [vpW, vpH] = (fp.fingerprint && fp.fingerprint.viewport
|
||||||
|
? fp.fingerprint.viewport
|
||||||
|
: '1366x768'
|
||||||
|
)
|
||||||
|
.split('x')
|
||||||
|
.map((n) => parseInt(n, 10));
|
||||||
|
const locale = (fp.fingerprint && fp.fingerprint.locale) || 'en-US';
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: HEADLESS });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent,
|
||||||
|
locale,
|
||||||
|
viewport: { width: vpW || 1366, height: vpH || 768 },
|
||||||
|
extraHTTPHeaders: headers,
|
||||||
|
});
|
||||||
|
await context.addInitScript({ content: stealth.script });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
|
||||||
|
// Click the "I'm not a robot" checkbox inside the anchor iframe.
|
||||||
|
const anchor = page.frameLocator('iframe[title="reCAPTCHA"]');
|
||||||
|
await anchor.locator('#recaptcha-anchor').click({ timeout: 20000 });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// If the checkbox alone passed (low-friction path), we're done.
|
||||||
|
token = await readToken(page);
|
||||||
|
if (token) {
|
||||||
|
log('token obtained from checkbox alone (no image challenge).');
|
||||||
|
} else {
|
||||||
|
// Image challenge lives in the "challenge" iframe.
|
||||||
|
const challenge = page.frameLocator('iframe[title*="challenge"]');
|
||||||
|
|
||||||
|
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||||
|
let instruction = '';
|
||||||
|
try {
|
||||||
|
instruction = (
|
||||||
|
await challenge.locator('.rc-imageselect-instructions').innerText({ timeout: 8000 })
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
log(`round ${round}: no challenge instructions visible; stopping.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridTable = challenge.locator('table');
|
||||||
|
const box = await gridTable.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
log(`round ${round}: could not locate the grid table; stopping.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = await challenge.locator('table td').count();
|
||||||
|
const cols = Math.max(1, Math.round(Math.sqrt(cells)));
|
||||||
|
const rows = Math.max(1, Math.round(cells / cols));
|
||||||
|
|
||||||
|
const png = await gridTable.screenshot();
|
||||||
|
const screenshot = `data:image/png;base64,${png.toString('base64')}`;
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
log(
|
||||||
|
`round ${round}: instruction="${instruction}" tiles=${JSON.stringify(grid.tiles)} ` +
|
||||||
|
`conf=${grid.confidence} recheck=${grid.recheck_recommended}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (grid.tiles && grid.tiles.length > 0) {
|
||||||
|
// Map tiles -> page-absolute click targets (prefer brain centroids).
|
||||||
|
const clickActions = grid.tiles.map((tileIndex, i) => {
|
||||||
|
const c = centroidXY(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 });
|
||||||
|
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();
|
||||||
|
if (step.at_ms != null) await page.waitForTimeout(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.recheck_recommended && grid.tiles && grid.tiles.length > 0) {
|
||||||
|
await page.waitForTimeout(900); // dynamic tiles fade in; re-screenshot next round.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the current selection.
|
||||||
|
try {
|
||||||
|
await challenge.locator('#recaptcha-verify-button').click({ timeout: 8000 });
|
||||||
|
} catch {
|
||||||
|
log(`round ${round}: verify button not clickable; stopping.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
|
||||||
|
token = await readToken(page);
|
||||||
|
if (token) break;
|
||||||
|
|
||||||
|
// No token yet — Google likely served a fresh challenge; loop and re-solve.
|
||||||
|
log(`round ${round}: no token yet; re-checking for a new challenge.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token = token || (await readToken(page));
|
||||||
|
} catch (err) {
|
||||||
|
// A genuine script/transport error -> non-zero exit.
|
||||||
|
console.error('[recaptcha-v2] ERROR:', err && err.stack ? err.stack : err);
|
||||||
|
mcp.close();
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mcp.close();
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
log(`OUTCOME: token obtained (length=${token.length}). reCAPTCHA v2 solved.`);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'OUTCOME: no token obtained. Real Google reCAPTCHA commonly resists headless automation; ' +
|
||||||
|
'this is expected for the best-effort path. Exiting 0.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exit(0); // best-effort: success regardless of token, only errors fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[recaptcha-v2] ERROR:', err && err.stack ? err.stack : err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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