Source: two_captcha_client.js

'use strict';
const fs = require('fs');
const { promisify } = require('util');

const Captcha = require('./captcha');
const constants = require('./constants');
const HTTPRequest = require('./http_request');

const baseUrl = 'https://2captcha.com/<action>.php';
const readFileAsync = promisify(fs.readFile);

class TwoCaptchaClient {
  /**
   * Constructor for the 2Captcha client object
   *
   * @param  {string}  key                  Your 2Captcha API key
   * @param  {Object}  [params]             Params for the client
   * @param  {number}  [params.timeout]     milliseconds before giving up on an captcha
   * @param  {number}  [params.polling]     milliseconds between polling for answer
   * @param  {Boolean} [params.throwErrors] Whether the client should throw errors or just log the errors
   * @return {TwoCaptchaClient}             The client object
   */
  constructor(key, {
    timeout = 60000,
    polling = 5000,
    throwErrors = false
  } = {}) {
    this.key = key;
    this.timeout = timeout;
    this.polling = polling;
    this.throwErrors = throwErrors;

    if (typeof (key) !== 'string') this._throwError('2Captcha key must be a string');
  }

  /**
   * Get balance from your account
   *
   * @return {Promise<float>} Account balance in USD
   */
  async balance() {
    let res = await this._request('res', 'get', {
      action: 'getbalance'
    });
    return res;
  }

  /**
   * Gets the response from a solved captcha
   *
   * @param  {string} captchaId The id of the desired captcha
   * @return {Promis<Captcha>}  A promise for the captcha
   */
  async captcha(captchaId) {
    const res = await this._request('res', 'get', {
      id: captchaId,
      action: 'get'
    });

    let decodedCaptcha = new Captcha();
    decodedCaptcha.id = captchaId;
    decodedCaptcha.apiResponse = res;
    decodedCaptcha.text = res.split('|', 2)[1];

    return decodedCaptcha;
  }


  /**
   * Sends an image captcha and polls for its response
   *
   * @param  {Object} options          Parameters for the requests
   * @param  {string} [options.base64] An already base64-coded image
   * @param  {Buffer} [options.buffer] A buffer object of a binary image
   * @param  {string} [options.path]   The path for a system-stored image
   * @param  {string} [options.url]    Url for a web-located image
   * @param  {string} [options.method] 2Captcha method of image sending. Can be either base64 or multipart
   * @return {Promise<Captcha>}        Promise for a Captcha object
   */
  async decode(options = {}) {
    const startedAt = Date.now();

    if (typeof (this.key) !== 'string') this._throwError('2Captcha key must be a string')

    let base64 = await this._loadCaptcha(options);

    let decodedCaptcha = await this._upload({ ...options, base64: base64 });

    // Keep pooling untill the answer is ready
    while (!decodedCaptcha.text) {
      await this._sleep(this.polling);
      if (Date.now() - startedAt > this.timeout) {
        this._throwError('Captcha timeout');
        return;
      }
      decodedCaptcha = await this.captcha(decodedCaptcha.id);
    }

    return decodedCaptcha;
  }

  /**
   * Sends a ReCaptcha v2 and polls for its response
   *
   * @param  {Object}  options            Parameters for the request
   * @param  {string}  options.googlekey  The google key from the ReCaptcha
   * @param  {string}  options.pageurl    The URL where the ReCaptcha is
   * @param  {boolean} options.invisible  Invisible ReCaptcha switch
   * @param  {boolean} options.enterprise Enterprise ReCaptcha switch
   * @return {Promise<Captcha>}           Promise for a Captcha object
   */
  async decodeRecaptchaV2(options = {}) {
    let startedAt = Date.now();

    if (options.googlekey === '') this._throwError('Missing googlekey parameter');
    if (options.pageurl === '') this._throwError('Missing pageurl parameter');

    let upload_options = {
      method: 'userrecaptcha',
      googlekey: options.googlekey,
      pageurl: options.pageurl,
      invisible: options.invisible ? 1 : 0,
      enterprise: options.enterprise ? 1 : 0,
    };

    let decodedCaptcha = await this._upload(upload_options);

    // Keep pooling untill the answer is ready
    while (!decodedCaptcha.text) {
      await this._sleep(Math.max(this.polling, 10)); // Sleep at least 10 seconds
      if (Date.now() - startedAt > this.timeout) {
        this._throwError('Captcha timeout');
        return;
      }
      decodedCaptcha = await this.captcha(decodedCaptcha.id);
    }

    return decodedCaptcha;
  }

  /**
     * Sends a ReCaptcha v3 and polls for its response
     *
     * @param  {Object} options             Parameters for the request
     * @param  {string} options.googlekey   The google key from the ReCaptcha
     * @param  {string} options.pageurl     The URL where the ReCaptcha is
     * @param  {string} options.action      Action value for ReCaptcha
     * @param  {boolean} options.enterprise Enterprise ReCaptcha switch
     * @return {Promise<Captcha>}           Promise for a Captcha object
     */
  async decodeRecaptchaV3(options = {}) {
    let startedAt = Date.now();

    if (options.googlekey === '') this._throwError('Missing googlekey parameter');
    if (options.pageurl === '') this._throwError('Missing pageurl parameter');

    let upload_options = {
      method: 'userrecaptcha',
      googlekey: options.googlekey,
      pageurl: options.pageurl,
      version: 'v3',
      action: options.action ? options.action : '',
      enterprise: options.enterprise ? 1 : 0,
    };

    let decodedCaptcha = await this._upload(upload_options);

    // Keep pooling untill the answer is ready
    while (!decodedCaptcha.text) {
      await this._sleep(Math.max(this.polling, 10)); // Sleep at least 10 seconds
      if (Date.now() - startedAt > this.timeout) {
        this._throwError('Captcha timeout');
        return;
      }
      decodedCaptcha = await this.captcha(decodedCaptcha.id);
    }

    return decodedCaptcha;
  }

  /**
   * Sends a hCaptcha and polls for its response
   *
   * @param  {Object} options             Parameters for the request
   * @param  {string} options.sitekey     The site key of the hCaptcha
   * @param  {string} options.pageurl     The URL where the hCaptcha is
   * @param  {boolean} options.invisible  Invisible hCaptcha
   * @return {Promise<Captcha>}           Promise for a Captcha object
  */
  async decodeHCaptcha(options = {}) {
    let startedAt = Date.now();

    if (options.sitekey === '') this._throwError('Missing sitekey parameter');
    if (options.pageurl === '') this._throwError('Missing pageurl parameter');

    let upload_options = {
      method: 'hcaptcha',
      sitekey: options.sitekey,
      pageurl: options.pageurl,
      invisible: options.invisible ? 1 : 0,
    };

    let decodedCaptcha = await this._upload(upload_options);

    // Keep pooling untill the answer is ready
    while (!decodedCaptcha.text) {
      await this._sleep(Math.max(this.polling, 10)); // Sleep at least 10 seconds
      if (Date.now() - startedAt > this.timeout) {
        this._throwError('Captcha timeout');
        return;
      }
      decodedCaptcha = await this.captcha(decodedCaptcha.id);
    }

    return decodedCaptcha;
  }

  /**
   * @deprecated /load.php route is returning error 500
   * Get current load from 2Captcha service
   *
   * @return {Promise<string>} Promise for an XML containing current load from
   * 2Captcha service
   */
  async load() {
    return await this._request('load', 'get');
  }

  /**
   * Loads a captcha image and converts to base64
   *
   * @param  {Object} options          The source of the image
   * @param  {string} [options.base64] An already base64-coded image
   * @param  {Buffer} [options.buffer] A buffer object of a binary image
   * @param  {string} [options.path]   The path for a system-stored image
   * @param  {string} [options.url]    Url for a web-located image
   * @return {Promise<string>}         Promise for a base64 string representation of an image
   */
  async _loadCaptcha(options = {}) {
    if (options.base64) {
      return options.base64;
    } else if (options.buffer) {
      return options.buffer.toString('base64');
    } else if (options.path) {
      let fileBinary = await readFileAsync(options.path);
      return new Buffer.from(fileBinary, 'binary').toString('base64');
    } else if (options.url) {
      let image = await HTTPRequest.openDataURL(options.url);
      return new Buffer.from(image, 'binary').toString('base64');
    } else {
      this._throwError('No image data received');
    }
  }

  /**
   * Makes a HTTP request for the 2Captcha API
   *
   * @param  {string} action   Path used in the 2Captcha api URL
   * @param  {string} method   HTTP verb to be used
   * @param  {string} payload  Body of the requisition
   * @return {Promise<string>} Promise for the response body
   */
  async _request(action, method = 'get', payload = {}) {
    let req = await HTTPRequest.request({
      url: baseUrl.replace('<action>', action),
      timeout: this.timeout,
      method: method,
      payload: { ...payload, key: this.key, soft_id: 2386 }
    });

    this._validateResponse(req);

    return req;
  }

  /**
   * Report incorrectly solved captcha for refund
   *
   * @param  {string} captchaId The id of the incorrectly solved captcha
   * @param {boolean} bad If reporting an incorrectly solved captcha. Default is true.
   * @return {Promise<Boolean>} Promise for a boolean informing if the report
   * was received
   */
  async report(captchaId, bad = true) {
    let res = await this._request('res', 'get', {
      action: bad ? 'reportbad' : 'reportgood',
      id: captchaId
    });
    return res === 'OK_REPORT_RECORDED';
  }

  /**
   * Blocks the code for the specified amount of time
   *
   * @param  {number} ms          The time in milliseconds to block the code
   * @return {Promise<undefined>} Promise for undefined that resolves after ms milliseconds
   */
  async _sleep(ms) {
    return new Promise(resolve => {
      setTimeout(resolve, ms)
    });
  }

  /**
   * Get usage statistics from your account
   *
   * @param  {Date} date       Date for the target day
   * @return {Promise<string>} Promise for an XML containing statistics about
   * target day
   */
  async stats(date) {
    let res = await this._request('res', 'get', {
      action: 'getstats',
      date: date.toISOString().slice(0, 10)
    });
    return res;
  }

  /**
   * Throws an Error if this.throwErrors is true. If this.throwErrors is false,
   * a warn is logged in the console.
   *
   * @param  {string} message      Message of the error
   * @return {(undefined|Boolean)} If an error wasn't thrown, returns false.
   */
  _throwError(message) {
    if (message === 'Your captcha is not solved yet.') return false;
    if (this.throwErrors) {
      throw new Error(message);
    } else {
      console.warn(message);
      return false;
    }
  }

  /**
   * Uploads a captcha for the 2Captcha API
   *
   * @param  {Object} options        Parametes for the controlling the requistion
   * @param  {string} options.base64 The base64 encoded image
   * @param  {string} options.method 2Captcha method of image sending. Can be either base64 or multipart
   * @return {Promise<Captcha>}      Promise for Captcha object containing the captcha ID
   */
  async _upload(options = {}) {
    let args = {};
    if (options.base64) args.body = options.base64;
    args.method = options.method || 'base64';

    // Merge args with any other required field
    args = { ...args, ...options };

    // Erase unecessary fields
    delete args.base64;
    delete args.buffer;
    delete args.path;
    delete args.url;

    let res = await this._request('in', 'post', args);

    this._validateResponse(res);

    let decodedCaptcha = new Captcha();
    decodedCaptcha.id = res.split('|', 2)[1];

    return decodedCaptcha;
  }

  /**
   * Checks if the response from 2Captcha is an Error. It may throw an error if
   * the class parameter throwExceptions is true. If it is false, only a warning
   * will be logged.
   *
   * @param  {string} body         Body from the 2Captcha response
   * @return {(undefined|Boolean)} Returns true if response is valid
   */
  _validateResponse(body) {
    let message;
    if (constants.errors[body]) {
      message = constants.errors[body];
    } else if (body === '' || body.toString().includes('ERROR')) {
      message = `Unknown 2Captcha error: ${body}`;
    } else {
      return true;
    }

    return this._throwError(message);
  }

}

module.exports = TwoCaptchaClient;