Solve captchas through the 2captcha API, not its extension
The two Tradeify automations clicked the 2captcha *extension's* solve button and waited for it to go green. That needed a second extension installed and signed in, put a real mouse click on a button whose markup is 2captcha's to change, and gave a run no way to tell "still solving" from "not installed". Replaced with one `solveCaptcha` step. No mouse: the extension reads the widget's sitekey out of the page, the dashboard buys a token from 2captcha, and the extension puts it in the response field and fires the page's own callback. captcha-hook.js is the part that earns its keep. A widget rendered programmatically puts nothing in the DOM — no sitekey, and no callback. Most pages only enable submit from that callback, so filling the hidden field alone leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha `render` at document_start to hold on to what the page passed. Solves go through 2captcha's residential proxy. Its API rejects host names in proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached); the gateway was confirmed to work addressed that way. Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile. NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is detected as v2 and would be solved with the wrong task type. Everything else unsupported fails loudly instead. The API key and proxy credentials are hardcoded in the captcha route on request. Unlike the sqlite database, that file is in the repo, so they travel with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still override; neither has a field on the Settings page. Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair, and a mismatch shows as a red "restart your runner" banner that blames the wrong process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
25289cb9b3
commit
159389be35
@@ -0,0 +1,273 @@
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** A thin proxy over api.2captcha.com, so the runner never handles the key.
|
||||
*
|
||||
* The runner is a desktop process that may not even be on this machine; keeping
|
||||
* the key here means it lives in exactly one place — the settings table — and
|
||||
* a captcha solve is one more dashboard call like every other step.
|
||||
*/
|
||||
const API_BASE = 'https://api.2captcha.com';
|
||||
const UPSTREAM_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** 2captcha's residential proxy, so the solve comes from a residential address
|
||||
* rather than a datacentre one the site may already score badly.
|
||||
*
|
||||
* Hardcoded on request. Note what that means: unlike the sqlite database, this
|
||||
* file IS in the repo, so these credentials travel with it. Override without
|
||||
* editing code via TWOCAPTCHA_PROXY, or by PATCHing `twocaptcha_proxy` to
|
||||
* /api/settings; either accepts `none` to go back to solving proxyless.
|
||||
*
|
||||
* Caveat worth knowing before trusting it: the token is scored partly on the
|
||||
* address that solved it, while the form is submitted from THIS machine's
|
||||
* address. The two only match if the browser goes out through the same proxy,
|
||||
* which it does not. For reCAPTCHA v2 and Turnstile that is usually tolerated;
|
||||
* for anything scoring the IP harder, a mismatched proxy can be worse than
|
||||
* none. */
|
||||
const DEFAULT_PROXY = 'http://u3229993f561005c2-zone-custom:u3229993f561005c2@na.proxy.2captcha.com:2334';
|
||||
|
||||
interface Proxy {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** Settings first, then the environment, then the hardcoded default. `none`
|
||||
* anywhere in that chain means solve without a proxy. */
|
||||
function proxyConfig(): Proxy | null {
|
||||
const raw = (getSetting('twocaptcha_proxy') || process.env.TWOCAPTCHA_PROXY || DEFAULT_PROXY).trim();
|
||||
if (!raw || /^(none|off|proxyless)$/i.test(raw)) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(/^\w+:\/\//.test(raw) ? raw : `http://${raw}`);
|
||||
const port = Number(url.port);
|
||||
if (!url.hostname || !port) return null;
|
||||
return {
|
||||
type: url.protocol.replace(':', '').toLowerCase(),
|
||||
host: url.hostname,
|
||||
port,
|
||||
login: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 2captcha rejects host names in `proxyAddress` — it wants a literal IP — so
|
||||
* `na.proxy.2captcha.com` has to be resolved here. Cached: the gateway address
|
||||
* is stable, and a DNS lookup per solve would be a needless round trip. */
|
||||
const IS_IP = /^(\d{1,3}\.){3}\d{1,3}$|:/;
|
||||
const dnsCache = new Map<string, { ip: string; at: number }>();
|
||||
const DNS_TTL_MS = 5 * 60_000;
|
||||
|
||||
async function proxyAddress(host: string): Promise<string> {
|
||||
if (IS_IP.test(host)) return host;
|
||||
|
||||
const hit = dnsCache.get(host);
|
||||
if (hit && Date.now() - hit.at < DNS_TTL_MS) return hit.ip;
|
||||
|
||||
const { address } = await dnsLookup(host);
|
||||
dnsCache.set(host, { ip: address, at: Date.now() });
|
||||
return address;
|
||||
}
|
||||
|
||||
type Provider = 'recaptcha' | 'hcaptcha' | 'turnstile';
|
||||
|
||||
/** The slice of 2captcha's answer we act on. Both endpoints share the shape. */
|
||||
interface UpstreamResult {
|
||||
errorId?: number;
|
||||
errorCode?: string;
|
||||
errorDescription?: string;
|
||||
taskId?: number | string;
|
||||
status?: string;
|
||||
solution?: { token?: string; gRecaptchaResponse?: string };
|
||||
cost?: string;
|
||||
}
|
||||
|
||||
function message(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback;
|
||||
}
|
||||
|
||||
/** The 2captcha account these solves are billed to.
|
||||
*
|
||||
* Hardcoded on request, with the same caveat as the proxy below: this file is in
|
||||
* the repo, so the key travels with it. Anyone who ends up with a copy can spend
|
||||
* the balance. To bill another account, set TWOCAPTCHA_API_KEY, or PATCH
|
||||
* `twocaptcha_api_key` to /api/settings — the settings row still wins, it just
|
||||
* has no field on the Settings page any more. */
|
||||
const DEFAULT_API_KEY = 'bde31ec7a337243e7331fbb882111a62';
|
||||
|
||||
/** Settings first, then the environment, then the hardcoded default. */
|
||||
function apiKey(): string {
|
||||
return (getSetting('twocaptcha_api_key') || process.env.TWOCAPTCHA_API_KEY || DEFAULT_API_KEY).trim();
|
||||
}
|
||||
|
||||
/** Build the task 2captcha expects for this widget.
|
||||
*
|
||||
* Each type comes in two spellings: `…TaskProxyless` solves from 2captcha's own
|
||||
* address, `…Task` solves through the proxy named in `proxy`. Same fields
|
||||
* otherwise, so the only difference is the suffix and the five proxy keys.
|
||||
*/
|
||||
function buildTask(
|
||||
body: Record<string, unknown>,
|
||||
proxy: (Proxy & { address: string }) | null,
|
||||
): { task?: Record<string, unknown>; error?: string } {
|
||||
const provider = String(body.provider || '') as Provider;
|
||||
const websiteKey = String(body.sitekey || '').trim();
|
||||
const websiteURL = String(body.pageurl || '').trim();
|
||||
|
||||
if (!websiteKey) return { error: '`sitekey` is required' };
|
||||
if (!/^https?:\/\//.test(websiteURL)) return { error: '`pageurl` must be an http(s) URL' };
|
||||
|
||||
const suffix = proxy ? '' : 'Proxyless';
|
||||
const common: Record<string, unknown> = {
|
||||
websiteURL,
|
||||
websiteKey,
|
||||
...(proxy ? {
|
||||
proxyType: proxy.type,
|
||||
proxyAddress: proxy.address,
|
||||
proxyPort: proxy.port,
|
||||
proxyLogin: proxy.login,
|
||||
proxyPassword: proxy.password,
|
||||
} : {}),
|
||||
// The browser that will submit the form. Sent only alongside a proxy:
|
||||
// a solve routed through a residential address is more convincing when
|
||||
// it also claims the same browser, and 2captcha ignores it otherwise.
|
||||
...(proxy && body.userAgent ? { userAgent: String(body.userAgent) } : {}),
|
||||
};
|
||||
|
||||
if (provider === 'turnstile') {
|
||||
return {
|
||||
task: {
|
||||
type: `TurnstileTask${suffix}`,
|
||||
...common,
|
||||
// Only present when the page rendered the widget programmatically
|
||||
// with them. Sending empty ones makes 2captcha reject the task.
|
||||
...(body.action ? { action: String(body.action) } : {}),
|
||||
...(body.data ? { data: String(body.data) } : {}),
|
||||
...(body.pagedata ? { pagedata: String(body.pagedata) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'hcaptcha') {
|
||||
return {
|
||||
task: {
|
||||
type: `HCaptchaTask${suffix}`,
|
||||
...common,
|
||||
isInvisible: !!body.invisible,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'recaptcha') {
|
||||
return {
|
||||
task: {
|
||||
type: body.enterprise ? `RecaptchaV2EnterpriseTask${suffix}` : `RecaptchaV2Task${suffix}`,
|
||||
...common,
|
||||
isInvisible: !!body.invisible,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { error: `unknown captcha provider ${provider || '(none)'}` };
|
||||
}
|
||||
|
||||
async function callUpstream(path: string, payload: unknown): Promise<UpstreamResult> {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`2captcha ${path} -> HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Queue a solve. Returns the 2captcha task id to poll with GET. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const clientKey = apiKey();
|
||||
if (!clientKey) {
|
||||
return corsJson(
|
||||
{ error: 'No 2captcha API key — set TWOCAPTCHA_API_KEY, or `twocaptcha_api_key` via /api/settings' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return corsJson({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxy = proxyConfig();
|
||||
let resolved: (Proxy & { address: string }) | null = null;
|
||||
if (proxy) {
|
||||
try {
|
||||
resolved = { ...proxy, address: await proxyAddress(proxy.host) };
|
||||
} catch (err) {
|
||||
return corsJson(
|
||||
{ error: `cannot resolve proxy host ${proxy.host}: ${message(err, 'DNS lookup failed')}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { task, error } = buildTask(body, resolved);
|
||||
if (!task) return corsJson({ error }, { status: 400 });
|
||||
|
||||
try {
|
||||
const out = await callUpstream('createTask', { clientKey, task, softId: 0 });
|
||||
if (out.errorId) {
|
||||
// A rejected task is 2captcha's verdict, not a broken pipeline — a bad
|
||||
// key or an empty balance both land here, and the runner reports them
|
||||
// as the step failure they are.
|
||||
return corsJson({ error: `${out.errorCode}: ${out.errorDescription}` }, { status: 400 });
|
||||
}
|
||||
return corsJson({
|
||||
taskId: out.taskId,
|
||||
type: task.type,
|
||||
// Echoed so the run log says which address solved it — the first
|
||||
// thing to check when a token comes back and the site rejects it.
|
||||
via: resolved ? `${proxy!.host}:${proxy!.port} (${resolved.address})` : 'no proxy',
|
||||
});
|
||||
} catch (err) {
|
||||
return corsJson({ error: message(err, 'createTask failed') }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll one task. `processing` means keep waiting; `failed` means this attempt
|
||||
* is dead and a fresh task is the only way forward. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const clientKey = apiKey();
|
||||
if (!clientKey) return corsJson({ error: 'No 2captcha API key configured' }, { status: 400 });
|
||||
|
||||
const taskId = req.nextUrl.searchParams.get('taskId');
|
||||
if (!taskId) return corsJson({ error: '`taskId` is required' }, { status: 400 });
|
||||
|
||||
try {
|
||||
const out = await callUpstream('getTaskResult', { clientKey, taskId: Number(taskId) || taskId });
|
||||
if (out.errorId) {
|
||||
return corsJson({ status: 'failed', error: `${out.errorCode}: ${out.errorDescription}` });
|
||||
}
|
||||
if (out.status !== 'ready') return corsJson({ status: 'processing' });
|
||||
|
||||
// Turnstile and hCaptcha answer with `token`, reCAPTCHA with
|
||||
// `gRecaptchaResponse`. Same string, two names.
|
||||
const token = out.solution?.token || out.solution?.gRecaptchaResponse || '';
|
||||
if (!token) return corsJson({ status: 'failed', error: 'solved, but the answer carried no token' });
|
||||
return corsJson({ status: 'ready', token, cost: out.cost ?? null });
|
||||
} catch (err) {
|
||||
return corsJson({ error: message(err, 'getTaskResult failed') }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
|
||||
Reference in New Issue
Block a user