/ scripts / benchmark-captcha-providers.js
benchmark-captcha-providers.js
  1  #!/usr/bin/env node
  2  /**
  3   * Benchmark CAPTCHA solving providers by submitting a test reCAPTCHA job to each
  4   * and measuring time to solution. Updates data/captcha-provider-benchmark.json.
  5   * Uses the public reCAPTCHA v2 demo sitekey which all providers accept for testing.
  6   */
  7  import { readFileSync, writeFileSync } from 'fs';
  8  import { join, dirname } from 'path';
  9  import { fileURLToPath } from 'url';
 10  import Database from 'better-sqlite3';
 11  import '../src/utils/load-env.js';
 12  
 13  const __dirname = dirname(fileURLToPath(import.meta.url));
 14  const projectRoot = join(__dirname, '..');
 15  const BENCHMARK_PATH = join(projectRoot, 'data', 'captcha-provider-benchmark.json');
 16  
 17  // Public demo sitekey accepted by all solving services for testing
 18  const TEST_SITEKEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI';
 19  const TEST_URL = 'https://www.google.com/recaptcha/api2/demo';
 20  
 21  function loadBenchmark() {
 22    try {
 23      return JSON.parse(readFileSync(BENCHMARK_PATH, 'utf8'));
 24    } catch {
 25      return {};
 26    }
 27  }
 28  
 29  function saveBenchmark(data) {
 30    writeFileSync(BENCHMARK_PATH, JSON.stringify(data, null, 2));
 31  }
 32  
 33  async function benchmarkNopeCHA(apiKey) {
 34    if (!apiKey) return null;
 35    const start = Date.now();
 36    const submitRes = await fetch('https://api.nopecha.com/token', {
 37      method: 'POST',
 38      headers: { 'Content-Type': 'application/json' },
 39      body: JSON.stringify({ key: apiKey, type: 'recaptcha2', sitekey: TEST_SITEKEY, url: TEST_URL }),
 40    });
 41    const submitData = await submitRes.json();
 42    if (submitData.error) {
 43      console.log(`NopeCHA submit error ${submitData.error}`);
 44      return null;
 45    }
 46    const jobId = submitData.data;
 47    for (let i = 0; i < 60; i++) {
 48      await new Promise(r => setTimeout(r, 3000));
 49      const res = await fetch(`https://api.nopecha.com/token?key=${apiKey}&id=${jobId}`);
 50      const data = await res.json();
 51      if (data.error === 14) continue;
 52      if (data.error) {
 53        console.log(`NopeCHA poll error ${data.error}`);
 54        return null;
 55      }
 56      if (data.data && typeof data.data === 'string' && data.data.length > 20)
 57        return Date.now() - start;
 58    }
 59    return null;
 60  }
 61  
 62  async function benchmarkCapMonster() {
 63    const apiKey = process.env.CAPMONSTER_API_KEY;
 64    if (!apiKey) return null;
 65    const start = Date.now();
 66    const createRes = await fetch('https://api.capmonster.cloud/createTask', {
 67      method: 'POST',
 68      headers: { 'Content-Type': 'application/json' },
 69      body: JSON.stringify({
 70        clientKey: apiKey,
 71        task: { type: 'RecaptchaV2TaskProxyless', websiteURL: TEST_URL, websiteKey: TEST_SITEKEY },
 72      }),
 73    });
 74    const createData = await createRes.json();
 75    if (createData.errorId !== 0) {
 76      console.log(`CapMonster error ${createData.errorId}: ${createData.errorDescription}`);
 77      return null;
 78    }
 79    const { taskId } = createData;
 80    for (let i = 0; i < 60; i++) {
 81      await new Promise(r => setTimeout(r, 3000));
 82      const res = await fetch('https://api.capmonster.cloud/getTaskResult', {
 83        method: 'POST',
 84        headers: { 'Content-Type': 'application/json' },
 85        body: JSON.stringify({ clientKey: apiKey, taskId }),
 86      });
 87      const data = await res.json();
 88      if (data.errorId !== 0) {
 89        console.log(`CapMonster poll error ${data.errorId}`);
 90        return null;
 91      }
 92      if (data.status === 'ready') return Date.now() - start;
 93    }
 94    return null;
 95  }
 96  
 97  function updateBenchmark(benchmark, name, ms, envKey) {
 98    if (ms !== null) {
 99      const prev = benchmark[name] || { avgMs: ms, count: 0 };
100      const count = prev.count + 1;
101      benchmark[name] = {
102        avgMs: Math.round((prev.avgMs * prev.count + ms) / count),
103        count,
104        lastMs: ms,
105        updated_at: new Date().toISOString(),
106      };
107      console.log(
108        `${name}: ${Math.round(ms / 1000)}s (avg: ${Math.round(benchmark[name].avgMs / 1000)}s)`
109      );
110    } else if (process.env[envKey]) {
111      benchmark[name] = {
112        ...benchmark[name],
113        lastMs: Infinity,
114        updated_at: new Date().toISOString(),
115      };
116      console.log(`${name}: failed/timeout`);
117    }
118  }
119  
120  function recentFormSends() {
121    const dbPath = process.env.DATABASE_PATH || join(projectRoot, 'db/sites.db');
122    try {
123      const db = new Database(dbPath, { readonly: true });
124      const row = db.prepare(
125        "SELECT COUNT(*) AS n FROM messages WHERE contact_method='form' AND sent_at > datetime('now','-1 hour')"
126      ).get();
127      db.close();
128      return row?.n ?? 0;
129    } catch {
130      return 0;
131    }
132  }
133  
134  async function main() {
135    const sends = recentFormSends();
136    if (sends < 10) {
137      console.log(`Skipping benchmark — only ${sends} form sends in the last hour (need ≥10).`);
138      process.exit(0);
139    }
140    console.log(`Benchmarking CAPTCHA providers (${sends} form sends in last hour)...`);
141    const benchmark = loadBenchmark();
142  
143    const [nopechaMs, nopecha2Ms, capmonsterMs] = await Promise.all([
144      benchmarkNopeCHA(process.env.NOPECHA_API_KEY).catch(() => null),
145      benchmarkNopeCHA(process.env.NOPECHA_API_KEY_2).catch(() => null),
146      benchmarkCapMonster().catch(() => null),
147    ]);
148  
149    updateBenchmark(benchmark, 'nopecha', nopechaMs, 'NOPECHA_API_KEY');
150    updateBenchmark(benchmark, 'nopecha2', nopecha2Ms, 'NOPECHA_API_KEY_2');
151    updateBenchmark(benchmark, 'capmonster', capmonsterMs, 'CAPMONSTER_API_KEY');
152  
153    saveBenchmark(benchmark);
154  
155    const winner = Object.entries(benchmark)
156      .filter(([, v]) => v.avgMs && v.avgMs < Infinity)
157      .sort(([, a], [, b]) => a.avgMs - b.avgMs)[0];
158    if (winner)
159      console.log(`Fastest provider: ${winner[0]} (avg ${Math.round(winner[1].avgMs / 1000)}s)`);
160  }
161  
162  main().catch(console.error);