Greasy Fork

Greasy Fork is available in English.

FR:Reborn - Agents extension

Upload Taobao and Yupoo QCs from your favorite agent to Imgur + QC server

当前为 2021-06-07 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         FR:Reborn - Agents extension
// @namespace    https://www.reddit.com/user/RobotOilInc
// @version      1.0.5
// @description  Upload Taobao and Yupoo QCs from your favorite agent to Imgur + QC server
// @author       RobotOilInc
// @match        https://www.basetao.net/index/myhome/myorder/*
// @match        https://basetao.net/index/myhome/myorder/*
// @match        https://www.basetao.com/index/myhome/myorder/*
// @match        https://basetao.com/index/myhome/myorder/*
// @match        https://superbuy.com/order*
// @match        https://www.superbuy.com/order*
// @match        https://wegobuy.com/order*
// @match        https://www.wegobuy.com/order*
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @grant        GM_registerMenuCommand
// @license      MIT
// @homepageURL  https://www.qc-server.cf/
// @supportURL   http://greasyfork.icu/en/scripts/426977-fr-reborn-agents-extension
// @include      https://www.basetao.net/index/orderphoto/itemimg/*
// @include      https://basetao.net/index/orderphoto/itemimg/*
// @include      https://www.basetao.com/index/orderphoto/itemimg/*
// @include      https://basetao.com/index/orderphoto/itemimg/*
// @require      https://unpkg.com/sweetalert2@11/dist/sweetalert2.min.js
// @require      https://unpkg.com/[email protected]/src/logger.min.js
// @require      https://unpkg.com/[email protected]/spark-md5.min.js
// @require      https://unpkg.com/[email protected]/dist/jquery.min.js
// @require      https://unpkg.com/@phamthaibaoduy/[email protected]/dist/jquery.ajax-retry.min.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.min.js
// @require      https://unpkg.com/@sentry/[email protected]/build/bundle.tracing.min.js
// @require      https://unpkg.com/[email protected]/dist/swagger-client.browser.min.js
// @require      http://greasyfork.icu/scripts/11562-gm-config-8/code/GM_config%208+.js?version=66657
// @resource     sweetalert2 https://unpkg.com/[email protected]/dist/sweetalert2.min.css
// @run-at       document-end
// @icon         https://i.imgur.com/mYBHjAg.png
// ==/UserScript==

const removeWhitespaces = (item) => item.trim().replace(/\s(?=\s)/g, '');

/**
 * @param input {string}
 * @param maxLength {number} must be an integer
 * @returns {string}
 */
const truncate = function (input, maxLength) {
  function isHighSurrogate(codePoint) {
    return codePoint >= 0xd800 && codePoint <= 0xdbff;
  }

  function isLowSurrogate(codePoint) {
    return codePoint >= 0xdc00 && codePoint <= 0xdfff;
  }

  function getLength(segment) {
    if (typeof segment !== 'string') {
      throw new Error('Input must be string');
    }

    const charLength = segment.length;
    let byteLength = 0;
    let codePoint = null;
    let prevCodePoint = null;
    for (let i = 0; i < charLength; i++) {
      codePoint = segment.charCodeAt(i);
      // handle 4-byte non-BMP chars
      // low surrogate
      if (isLowSurrogate(codePoint)) {
        // when parsing previous hi-surrogate, 3 is added to byteLength
        if (prevCodePoint != null && isHighSurrogate(prevCodePoint)) {
          byteLength += 1;
        } else {
          byteLength += 3;
        }
      } else if (codePoint <= 0x7f) {
        byteLength += 1;
      } else if (codePoint >= 0x80 && codePoint <= 0x7ff) {
        byteLength += 2;
      } else if (codePoint >= 0x800 && codePoint <= 0xffff) {
        byteLength += 3;
      }
      prevCodePoint = codePoint;
    }

    return byteLength;
  }

  if (typeof input !== 'string') {
    throw new Error('Input must be string');
  }

  const charLength = input.length;
  let curByteLength = 0;
  let codePoint;
  let segment;

  for (let i = 0; i < charLength; i += 1) {
    codePoint = input.charCodeAt(i);
    segment = input[i];

    if (isHighSurrogate(codePoint) && isLowSurrogate(input.charCodeAt(i + 1))) {
      i += 1;
      segment += input[i];
    }

    curByteLength += getLength(segment);

    if (curByteLength === maxLength) {
      return input.slice(0, i + 1);
    }
    if (curByteLength > maxLength) {
      return input.slice(0, i - segment.length + 1);
    }
  }

  return input;
};

/**
 * @param url {string}
 * @returns {Promise<string>}
 */
const toDataURL = (url) => fetch(url)
  .then((response) => response.blob())
  .then((blob) => new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  }));

/**
 * @param base64Data {string}
 * @returns {Promise<string>}
 */
const WebpToJpg = function (base64Data) {
  return new Promise((resolve) => {
    const image = new Image();
    image.src = base64Data;

    image.onload = () => {
      const canvas = document.createElement('canvas');
      const context = canvas.getContext('2d');

      canvas.width = image.width;
      canvas.height = image.height;
      context.drawImage(image, 0, 0);

      resolve(canvas.toDataURL('image/jpeg'));
    };
  });
};

// Possible sources
const TAOBAO = 'taobao';
const YUPOO = 'yupoo';
const WEIDIAN = 'weidian';
const UNKNOWN = 'unknown';

const isUrl = (string) => {
  try { return Boolean(new URL(string)); } catch (e) { return false; }
};

/**
 * @param url {string}
 * @returns {boolean}
 */
const supportedPurchaseWebsite = (url) => url.indexOf('item.taobao.com') !== -1
      || url.indexOf('tmall.com') !== -1
      || url.indexOf('yupoo.com') !== -1
      || url.indexOf('weidian.com') !== -1;

/**
 * @param originalUrl {string}
 * @param website {string}
 * @returns {string}
 */
const buildPurchaseUrl = (originalUrl, website) => {
  const idMatches = originalUrl.match(/[?&]id=(\d+)|[?&]itemID=(\d+)|\/albums\/(\d+)/i);
  const authorMatches = originalUrl.match(/https?:\/\/(\w+)\.x\.yupoo\.com/);

  if (website === TAOBAO) {
    return `https://item.taobao.com/item.htm?id=${idMatches[1]}`;
  }

  if (website === WEIDIAN) {
    return `https://weidian.com/item.html?itemID=${idMatches[2]}`;
  }

  if (website === YUPOO) {
    return `https://${authorMatches[1]}.x.yupoo.com/albums/${idMatches[3]}`;
  }

  throw new Error('could not build purchase URL');
};

/**
 * @param originalUrl {string}
 * @returns {string}
 */
const determineWebsite = (originalUrl) => {
  if (originalUrl.indexOf('item.taobao.com') !== -1
      || originalUrl.indexOf('tmall.com') !== -1) {
    return TAOBAO;
  }

  if (originalUrl.indexOf('yupoo.com') !== -1) {
    return YUPOO;
  }

  if (originalUrl.indexOf('weidian.com') !== -1) {
    return WEIDIAN;
  }

  return UNKNOWN;
};

class BaseTaoElement {
  constructor($element) {
    const $baseElement = $element.parents('tr').find('td[colspan=\'2\']').first();

    this.object = $element;
    this.imageUrls = [];

    this.qcImagesUrl = $element.parent().attr('href').trim();
    this.url = $baseElement.find('.goodsname_color').first().attr('href').trim();

    this.website = determineWebsite(this.url);
    this.title = truncate(removeWhitespaces($baseElement.find('.goodsname_color').first().text()), 255);

    // Item sizing (if any)
    const sizing = removeWhitespaces($baseElement.find('.size_color_color:nth-child(2) > u').text());
    this.size = sizing.length !== 0 ? truncate(sizing, 255) : null;

    // Item color (if any)
    let color = removeWhitespaces($baseElement.find('.size_color_color:nth-child(1) > u').text());
    color = (color !== '-' && color.toLowerCase() !== 'no') ? truncate(color, 255) : '';
    this.color = color.length !== 0 ? color : null;

    this.itemPrice = `CNY ${removeWhitespaces($element.parents('tr').find('td:nth-child(2) > span').first().text())}`;
    this.freightPrice = `CNY ${removeWhitespaces($element.parents('tr').find('td:nth-child(3) > span').first().text())}`;

    // eslint-disable-next-line prefer-destructuring
    this.orderId = this.qcImagesUrl.match(/itemimg\/(\d+)\.html/)[1];
  }

  /**
  * @returns {{color, size}}
  */
  get sizingInfo() {
    return { color: this.color, size: this.size };
  }

  /**
  * @param imageUrls {string[]}
  */
  set images(imageUrls) {
    this.imageUrls = imageUrls;
  }

  /**
   * @returns {string}
   */
  get purchaseUrl() {
    return buildPurchaseUrl(this.url, this.website);
  }
}

/**
 * @param text {string}
 * @param type {null|('success'|'error'|'warning'|'info')}
 */
const Snackbar = function (text, type = null) {
  if (typeof type !== 'undefined' && type != null) {
    Swal.fire({
      title: text,
      icon: type,
      position: 'bottom-end',
      showConfirmButton: false,
      allowOutsideClick: false,
      backdrop: false,
      timer: 1500,
    });

    return;
  }

  Swal.fire({
    title: text,
    position: 'bottom-end',
    showConfirmButton: false,
    allowOutsideClick: false,
    backdrop: false,
    timer: 1500,
  });
};

class Imgur {
  /**
   * @param version {string}
   * @param config {GM_config}
   * @param agent {string}
   * @constructor
   */
  constructor(version, config, agent) {
    this.version = version;
    this.agent = agent;

    if (config.get('imgurApi') === 'imgur') {
      this.headers = { authorization: `Client-ID ${config.get('imgurClientId')}` };
      this.host = config.get('imgurApiHost');

      return;
    }

    if (config.get('imgurApi') === 'rapidApi') {
      this.headers = {
        authorization: `Bearer ${config.get('rapidApiBearer')}`,
        'x-rapidapi-key': config.get('rapidApiKey'),
        'x-rapidapi-host': config.get('rapidApiHost'),
      };
      this.host = config.get('rapidApiHost');

      return;
    }

    throw new Error('Invalid Imgur API has been chosen');
  }

  /**
   * @param options
   * @returns {Promise<*|null>}
   */
  CreateAlbum(options) {
    return $.ajax({
      url: `https://${this.host}/3/album`,
      type: 'POST',
      headers: this.headers,
      data: {
        title: options.title,
        privacy: 'hidden',
        description: `Auto uploaded using FR:Reborn - ${this.agent} ${this.version}`,
      },
    }).retry({ times: 3 }).catch((err) => {
      // Log the error somewhere
      Logger.error(`Could not make an album: ${err.statusText}`, err);

      // If we uploaded too fast, tell the user
      if (err.responseJSON.data.error.code === 429) {
        Snackbar(`Imgur is telling us to slow down: ${err.responseJSON.data.error.message}`, 'error');
        return;
      }

      // Tell the user that "something" is wrong
      Snackbar('Could not make an album, please try again later...', 'error');
      Sentry.captureException(err);
    });
  }

  /**
   * @param base64Image {string}
   * @param deleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<*|null>}
   */
  async AddBase64ImageToAlbum(base64Image, deleteHash, purchaseUrl) {
    return $.ajax({
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: {
        album: deleteHash,
        type: 'base64',
        image: base64Image,
        description: `W2C: ${purchaseUrl}`,
      },
    }).retry({ times: 3 }).then(() => true).catch((err) => {
      // If we uploaded too many files, tell the user
      if (err.responseJSON.data.error.code === 429) {
        return false;
      }

      // Log the error otherwise
      Logger.error('An error happened when uploading the image', err);
      Sentry.captureException(err);

      return false;
    });
  }

  /**
   * @param imageUrl {string}
   * @param deleteHash {string}
   * @param purchaseUrl {string}
   * @returns {Promise<*|null>}
   */
  async AddImageToAlbum(imageUrl, deleteHash, purchaseUrl) {
    return $.ajax({
      url: `https://${this.host}/3/image`,
      headers: this.headers,
      type: 'POST',
      data: {
        album: deleteHash,
        image: imageUrl,
        description: `W2C: ${purchaseUrl}`,
      },
    }).retry({ times: 3 }).then(() => true).catch((err) => {
      // If we uploaded too many files, tell the user
      if (err.responseJSON.data.error.code === 429) {
        return false;
      }

      // Log the error otherwise
      Logger.error('An error happened when uploading the image', err);
      Sentry.captureException(err);

      return false;
    });
  }

  /**
   * @param deleteHash {string}
   */
  RemoveAlbum(deleteHash) {
    $.ajax({ url: `https://${this.host}/3/album/${deleteHash}`, headers: this.headers, type: 'DELETE' }).retry({ times: 3 });
  }
}

const buildSwaggerHTTPError = function (response) {
  // Build basic error (and use response as extra)
  const error = new Error(`${response.body.detail}: ${response.url}`);

  // Add status and status code
  error.status = response.body.status;
  error.statusCode = response.body.status;

  return error;
};

class QC {
  /**
     * @param version {string}
     * @param client {SwaggerClient}
     * @param userHash {string}
     */
  constructor(version, client, userHash) {
    this.version = version;
    this.client = client;
    this.userHash = userHash;
  }

  /**
     * @param element
     * @returns {Promise<null|string>}
     */
  existingAlbumByOrderId(element) {
    return this.client.apis.QualityControl.hasUploaded({
      usernameHash: this.userHash,
      orderId: element.orderId,
    }).then((response) => {
      if (typeof response.body === 'undefined') {
        return null;
      }

      if (!response.body.success) {
        return null;
      }

      return response.body.albumId;
    }).catch((reason) => {
      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
        Sentry.captureException(buildSwaggerHTTPError(reason.response));
        Logger.error('Could not check if the album exists on the QC server');

        return '-1';
      }

      Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
      Sentry.captureException(new Error('Could not check if the album exists on the QC server'));
      Logger.error('Could not check if the album exists on the QC server', reason);

      return '-1';
    });
  }

  /**
     * @param url {string}
     * @returns {Promise<boolean>}
     */
  exists(url) {
    return this.client.apis.QualityControl.exists({ url }).then((response) => {
      if (typeof response.body === 'undefined') {
        return null;
      }

      if (!response.body.success) {
        return null;
      }

      return response.body.exists;
    }).catch((reason) => {
      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
        Sentry.captureException(buildSwaggerHTTPError(reason.response));
        Logger.error('Could not check if the album exists on the QC server');

        return false;
      }

      Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
      Sentry.captureException(new Error('Could not check if the album exists on the QC server'));
      Logger.error('Could not check if the album exists on the QC server', reason);

      return false;
    });
  }

  /**
     * @param element {BaseTaoElement|WeGoBuyElement}
     * @param album {string}
     */
  uploadQc(element, album) {
    return this.client.apis.QualityControl.postQualityControlCollection({}, {
      method: 'post',
      requestContentType: 'application/json',
      requestBody: {
        usernameHash: this.userHash,
        albumId: album,
        color: element.color,
        orderId: element.orderId,
        purchaseUrl: element.purchaseUrl,
        sizing: element.size,
        itemPrice: element.itemPrice,
        freightPrice: element.freightPrice,
        source: `BaseTao to Imgur ${this.version}`,
        website: element.website,
      },
    }).catch((reason) => {
      // Swagger HTTP error
      if (typeof reason.response !== 'undefined') {
        Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
        Sentry.captureException(buildSwaggerHTTPError(reason.response));
        Logger.error('Could not check if the album exists on the QC server');

        return;
      }

      Sentry.addBreadcrumb({ category: 'Swagger', message: JSON.stringify(reason), level: Sentry.Severity.Debug });
      Sentry.captureException(new Error('Could not check if the album exists on the QC server'));
      Logger.error('Could not check if the album exists on the QC server', reason);
    });
  }
}

class BaseTao {
  constructor() {
    this.setup = false;
  }

  /**
  * @param hostname {string}
  * @returns {boolean}
  */
  supports(hostname) {
    return hostname.includes('basetao.com');
  }

  /**
   * @param client {SwaggerClient}
   * @returns {Promise<BaseTao>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    this.client = client;
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'BaseTao');

    // Get the username
    const username = $('#dropdownMenu1').text();
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');

      return this;
    }

    // Ensure we know who triggered the error, but use the username hash
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash });

    // Create QC client with user hash
    this.qcClient = new QC(GM_info.script.version, this.client, userHash);

    // Mark that this agent has been setup
    this.setup = true;

    return this;
  }

  /**
   * @param element {BaseTaoElement}
   * @returns {Promise<void>}
   */
  async uploadToImgur(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    const $processing = $('<ul><li><span style="cursor: wait;margin-left:-29px;"><img src="https://i.imgur.com/lnFQTQz.gif" alt="Processing..."></span></li></ul>');
    const $base = element.object.parents('td').first().find('ul:last-child').first();
    $base.after($processing).hide();

    Snackbar('Pictures are being uploaded....');

    // Create the album
    const response = await this.imgurClient.CreateAlbum(element);
    if (typeof response === 'undefined' || response == null) {
      return;
    }

    const deleteHash = response.data.deletehash;
    const albumId = response.data.id;

    const AlbumLink = `https://imgur.com/a/${albumId}`;

    // Upload all QC images
    let uploadedImages = 0;
    const promises = [];
    $.each(element.imageUrls, (key, imageUrl) => {
      // Convert to base64, since Imgur cannot access our images
      promises.push(toDataURL(imageUrl).then(async (data) => {
        // Store our base64 and if the file is WEBP, convert it to JPG
        let base64Image = data;
        if (base64Image.indexOf('image/webp') !== -1) {
          base64Image = await WebpToJpg(base64Image);
        }

        // Remove the unnecessary `data:` part
        const cleanedData = base64Image.replace(/(data:image\/.*;base64,)/, '');

        // Upload the image to the album
        return this.imgurClient.AddBase64ImageToAlbum(cleanedData, deleteHash, element.purchaseUrl);
      }).then((uploaded) => {
        if (uploaded === false) {
          return;
        }

        uploadedImages++;
      }));
    });

    // Wait until everything has been tried to be uploaded
    await Promise.all(promises);

    // If not all images have been uploaded, abort everything
    if (uploadedImages !== element.imageUrls.length) {
      Snackbar('Imgur is rate-limiting you, try again later.', 'error');
      this.imgurClient.RemoveAlbum(deleteHash);

      $processing.remove();
      $base.show();

      return;
    }

    // Tell the user it was uploaded and open the album in the background
    Snackbar('Pictures have been uploaded!', 'success');
    GM_openInTab(AlbumLink, true);

    // Tell QC Suite about our uploaded QC's (if it's from TaoBao)
    if (supportedPurchaseWebsite(element.url)) {
      this.qcClient.uploadQc(element, albumId);
    }

    // Wrap the logo in a href to the new album
    const $image = $base.find('img');
    $image.wrap(`<a href='${AlbumLink}' target='_blank' title='Go to album'></a>`);
    $image.removeAttr('title');

    // Remove processing
    $processing.remove();

    // Update the marker
    const $qcMarker = $base.find('.qc-marker:not(:first-child)').first();
    $qcMarker.attr('title', 'You have uploaded your QC')
      .css('cursor', 'help')
      .css('color', 'green')
      .text('✓');

    // Remove the click handler
    $base.off();

    // Show it again
    $base.show();
  }

  async uploadHandler(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Go to the QC pictures URL and grab all image src's
    $.get(element.qcImagesUrl, (data) => {
      if (data.indexOf('long time no operation ,please sign in again') !== -1) {
        Snackbar('You are no longer logged in, reloading page....', 'warning');
        Logger.info('No longer logged in, reloading page for user...');
        window.location.reload();

        return;
      }

      // Add all image urls to the element
      $('<div/>').html(data).find('div.container.container-top60 > img').each(function () {
        element.imageUrls.push($(this).attr('src'));
      });

      // Finally go and upload the order
      this.uploadToImgur(element);
    });
  }

  process() {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Make copy of the current this, so we can use it later
    const agent = this;

    $('.myparcels-ul').first().find('span.glyphicon.glyphicon-picture').each(async function () {
      const $this = $(this);
      const element = new BaseTaoElement($this);

      // This plugin only works for certain websites, so check if element is supported
      if (supportedPurchaseWebsite(element.url) === false) {
        const $upload = $('<ul><li><span style="cursor: pointer;margin-left:-29px;"><img src="https://s.imgur.com/images/favicon-16x16.png" alt="Create a basic album"></span></li></ul>');
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>'));
        $upload.on('click', () => { agent.uploadHandler(element); });

        $this.parents('td').first().append($upload);

        return;
      }

      const $loading = $('<ul><li><span style="cursor: wait;margin-left:-29px;"><img src="https://i.imgur.com/lnFQTQz.gif" alt="Loading..."></span></li></ul>');
      $this.parents('td').first().append($loading);

      // Define upload object
      const $upload = $('<ul><li><span class="qc-marker" style="cursor: pointer;margin-left:-29px;"><img src="https://s.imgur.com/images/favicon-16x16.png" alt="Upload your QC"></span></li></ul>');

      // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader.
      const albumId = await agent.qcClient.existingAlbumByOrderId(element);
      if (albumId === '-1') {
        $upload.find('span').first().html($('<span class="qc-marker" style="cursor:help;color:red;font-weight: bold;" title="FR:Reborn returned an error or could not load your album.">⚠️</span>'));

        $this.parents('td').first().append($upload);
        $loading.remove();

        return;
      }

      // Has anyone ever uploaded a QC, if not, show a red marker
      const exists = await agent.qcClient.exists(element.purchaseUrl);
      if (!exists) {
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</span>'));
        $upload.on('click', () => { agent.uploadHandler(element); });

        $this.parents('td').first().append($upload);
        $loading.remove();

        return;
      }

      // Have you ever uploaded a QC? If so, link to that album
      const $image = $upload.find('img');
      if (albumId !== null && albumId !== '-1') {
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>'));
        $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'></a>`);
        $image.removeAttr('title');

        $this.parents('td').first().append($upload);
        $loading.remove();

        return;
      }

      // A previous QC exists, but you haven't uploaded yours yet, show orange marker
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</span>'));
      $upload.on('click', () => { agent.uploadHandler(element); });

      $this.parents('td').first().append($upload);
      $loading.remove();
    });
  }
}

class WeGoBuyElement {
  constructor($element) {
    this.object = $element;

    // Ordere details
    this.orderId = removeWhitespaces($element.find('table > tbody > tr:nth-child(1) > td:nth-child(1) > p').text());
    this.imageUrls = $element.find('.lookPic').map((key, value) => $(value).attr('href')).get();

    // Item name
    this.title = truncate(removeWhitespaces($element.find('.js-item-title').first().text()), 255);

    // Item sizing (if any)
    const sizing = removeWhitespaces($element.find('.user_orderlist_txt').text());
    this.size = sizing.length !== 0 ? truncate(sizing, 255) : null;

    // Item color (WeGoBuy doesn't support seperation of color, so just null)
    this.color = null;

    // Price details
    const itemPriceMatches = truncate(removeWhitespaces($element.find('tbody > tr > td:nth-child(2)').first().text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/);
    this.itemPrice = `${itemPriceMatches[1]} ${itemPriceMatches[2]}`;
    const freightPriceMatches = truncate(removeWhitespaces($element.find('tbody > tr:nth-child(1) > td:nth-child(8) > p:nth-child(2)').first().text()), 255).match(/([A-Z]{2})\W+([.,0-9]+)/);
    this.freightPrice = `${freightPriceMatches[1]} ${freightPriceMatches[2]}`;

    // Purchase details
    const possibleUrl = removeWhitespaces($element.find('.js-item-title').first().attr('href')).trim();
    this.url = isUrl(possibleUrl) ? possibleUrl : '';
    if (this.url.length !== 0) {
      this.website = determineWebsite(this.url);
    }
  }

  /**
  * @returns {{color, size}}
  */
  get sizingInfo() {
    return { size: this.size };
  }

  /**
  * @param imageUrls {string[]}
  */
  set images(imageUrls) {
    this.imageUrls = imageUrls;
  }

  /**
   * @returns {string}
   */
  get purchaseUrl() {
    return buildPurchaseUrl(this.url, this.website);
  }
}

class WeGoBuy {
  constructor() {
    this.setup = false;
  }

  /**
  * @param hostname {string}
  * @returns {boolean}
  */
  supports(hostname) {
    return hostname.includes('wegobuy.com')
        || hostname.includes('superbuy.com');
  }

  /**
   * @param client {SwaggerClient}
   * @returns {Promise<WeGoBuy>}
   */
  async build(client) {
    // If already build before, just return
    if (this.setup) {
      return this;
    }

    this.client = client;
    this.imgurClient = new Imgur(GM_info.script.version, GM_config, 'WeGoBuy');

    // Get the username
    const username = (await $.get('/ajax/user-info')).data.user_name;
    if (typeof username === 'undefined' || username == null || username === '') {
      Snackbar('You need to be logged in to use this extension.');

      return this;
    }

    // Ensure we know who triggered the error, but use the username hash
    const userHash = SparkMD5.hash(username);
    Sentry.setUser({ id: userHash });

    // Create QC client
    this.qcClient = new QC(GM_info.script.version, this.client, userHash);

    // Mark that this agent has been setup
    this.setup = true;

    return this;
  }

  /**
   * @param element {WeGoBuyElement}
   * @returns {Promise<void>}
   */
  async UploadToImgur(element) {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    const $processing = $('<div><p>FR:Reborn:</p><span style="cursor: wait;"><img src="https://i.imgur.com/lnFQTQz.gif" alt="Processing..."></span></div>');
    const $options = element.object.find('tbody > tr > td:nth-child(7)').first();
    const $base = $options.find('div').first();
    $base.after($processing).hide();

    Snackbar('Pictures are being uploaded....');

    // Create the album
    const response = await this.imgurClient.CreateAlbum(element);
    if (typeof response === 'undefined' || response == null) {
      return;
    }

    const deleteHash = response.data.deletehash;
    const albumId = response.data.id;

    const AlbumLink = `https://imgur.com/a/${albumId}`;

    // Upload all QC images
    let uploadedImages = 0;
    const promises = [];
    $.each(element.imageUrls, (key, imageUrl) => {
      promises.push(this.imgurClient.AddImageToAlbum(imageUrl, deleteHash, element.purchaseUrl).then((uploaded) => {
        if (uploaded === false) {
          return;
        }

        uploadedImages++;
      }));
    });

    // Wait until everything has been tried to be uploaded
    await Promise.all(promises);

    // If not all images have been uploaded, abort everything
    if (uploadedImages !== element.imageUrls.length) {
      Snackbar('Imgur is rate-limiting you, try again later.', 'error');
      this.imgurClient.RemoveAlbum(deleteHash);

      $processing.remove();
      $base.show();

      return;
    }

    // Tell the user it was uploaded and open the album in the background
    Snackbar('Pictures have been uploaded!', 'success');
    GM_openInTab(AlbumLink, true);

    // Tell QC Suite about our uploaded QC's (if it's from TaoBao)
    if (supportedPurchaseWebsite(element.url)) {
      this.qcClient.uploadQc(element, albumId);
    }

    // Remove processing
    $processing.remove();
    $base.remove();

    // Add new buttons
    $options.append($('<div><p>FR:Reborn:</p>'
        + `<span class="qc-marker" style="cursor:pointer;"><a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'><img src="https://s.imgur.com/images/favicon-16x16.png" alt="Go to album"></a></span>`
        + '<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>'
        + '</div>'));

    // Remove the click handler
    $base.off();

    // Show it again
    $base.show();
  }

  process() {
    if (this.setup === false) {
      throw new Error('Agent is not setup, so cannot be used');
    }

    // Make copy of the current this, so we can use it later
    const agent = this;

    $('#table_list > div').each(async function () {
      const $this = $(this);
      const element = new WeGoBuyElement($this);

      // No pictures (like rehearsal orders), or no URL like service fees, no QC options
      if (element.imageUrls.length === 0 || element.url.length === 0) {
        return;
      }

      // This plugin only works for certain websites, so check if element is supported
      if (supportedPurchaseWebsite(element.url) === false) {
        const $upload = $('<div><p>FR:Reborn:</p><span style="cursor: pointer;"><img src="https://s.imgur.com/images/favicon-16x16.png" alt="Create a basic album"></span></div>');
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="Not a supported URL, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">✖</span>'));
        $upload.on('click', () => { agent.UploadToImgur(element); });

        $this.parents('td').first().append($upload);

        return;
      }

      // Define column in which to show buttons
      const $other = $this.find('tbody > tr > td:nth-child(7)').first();

      // Show simple loading animation
      const $loading = $('<div><p>FR:Reborn:</p><span style="cursor: wait;"><img src="https://i.imgur.com/lnFQTQz.gif" alt="Loading..."></span></div>');
      $other.append($loading);

      // Define upload object
      const $upload = $('<div><p>FR:Reborn:</p><span class="qc-marker" style="cursor: pointer;"><img src="https://s.imgur.com/images/favicon-16x16.png" alt="Upload your QC"></span></div>');

      // If we couldn't talk to FR:Reborn, assume everything is dead and use the basic uploader.
      const albumId = await agent.qcClient.existingAlbumByOrderId(element);
      if (albumId === '-1') {
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="FR:Reborn returned an error, but you can still create an album. The QC\'s are not stored and you\'ll have to create a new album if you lose the link.">⚠️</span>'));
        $upload.on('click', () => { agent.UploadToImgur(element); });

        $other.append($upload);
        $loading.remove();

        return;
      }

      // Has anyone ever uploaded a QC, if not, show a red marker
      const exists = await agent.qcClient.exists(element.purchaseUrl);
      if (!exists) {
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:red;font-weight: bold;" title="No QC in database, please upload.">(!)</span>'));
        $upload.on('click', () => { agent.UploadToImgur(element); });

        $other.append($upload);
        $loading.remove();

        return;
      }

      // Have you ever uploaded a QC? If so, link to that album
      const $image = $upload.find('img');
      if (albumId !== null && albumId !== '-1') {
        $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:green;font-weight: bold;" title="You have uploaded a QC">✓</span>'));
        $image.wrap(`<a href='https://imgur.com/a/${albumId}' target='_blank' title='Go to album'></a>`);
        $image.removeAttr('title');

        $other.append($upload);
        $loading.remove();

        return;
      }

      // A previous QC exists, but you haven't uploaded yours yet, show orange marker
      $upload.find('span').first().after($('<span class="qc-marker" style="cursor:help;margin-left:5px;color:orange;font-weight: bold;" title="Your QC is not yet in the database, please upload.">(!)</span>'));
      $upload.on('click', () => { agent.UploadToImgur(element); });

      $other.append($upload);
      $loading.remove();
    });
  }
}

/**
 * @param hostname {string}
 */
function getAgent(hostname) {
  const agents = [new BaseTao(), new WeGoBuy()];

  let agent = null;
  Object.values(agents).forEach((value) => {
    if (value.supports(hostname)) {
      agent = value;
    }
  });

  return agent;
}

// Inject snackbar css style
GM_addStyle(GM_getResourceText('sweetalert2'));

// Setup proper settings menu
GM_config.init('Settings', {
  serverSection: {
    label: 'QC Server settings',
    type: 'section',
  },
  swaggerDocUrl: {
    label: 'Swagger documentation URL',
    type: 'text',
    default: 'https://www.qc-server.cf/api/doc.json',
  },
  uploadSection: {
    label: 'Upload API Options',
    type: 'section',
  },
  imgurApi: {
    label: 'Select your Imgur API',
    type: 'radio',
    default: 'imgur',
    options: {
      imgur: 'Imgur API (Free)',
      rapidApi: 'RapidAPI (Freemium)',
    },
  },
  imgurSection: {
    label: 'Imgur Options',
    type: 'section',
  },
  imgurApiHost: {
    label: 'Imgur host',
    type: 'text',
    default: 'api.imgur.com',
  },
  imgurClientId: {
    label: 'Imgur Client-ID',
    type: 'text',
    default: 'e4e18b5ab582b4c',
  },
  rapidApiSection: {
    label: 'RadidAPI Options',
    type: 'section',
  },
  rapidApiHost: {
    label: 'RapidAPI host',
    type: 'text',
    default: 'imgur-apiv3.p.rapidapi.com',
  },
  rapidApiKey: {
    label: 'RapidAPI key (only needed if RapidApi select above)',
    type: 'text',
    default: '',
  },
  rapidApiBearer: {
    label: 'RapidAPI access token (only needed if RapidApi select above)',
    type: 'text',
    default: '',
  },
});

// Reload page if config changed
GM_config.onclose = (saveFlag) => {
  if (saveFlag) {
    window.location.reload();
  }
};

// Register menu within GM
GM_registerMenuCommand('Settings', GM_config.open);

// Setup Sentry for proper error logging, which will make my lives 20x easier, use XHR since fetch dies.
Sentry.init({
  dsn: 'https://[email protected]/5802425',
  transport: Sentry.Transports.XHRTransport,
  release: `agents-${GM_info.script.version}`,
  defaultIntegrations: false,
  integrations: [
    new Sentry.Integrations.InboundFilters(),
    new Sentry.Integrations.FunctionToString(),
    new Sentry.Integrations.LinkedErrors(),
    new Sentry.Integrations.UserAgent(),
  ],
  environment: 'production',
  normalizeDepth: 5,
});

// eslint-disable-next-line func-names
(async function () {
  // Setup the logger.
  Logger.useDefaults();

  // Log the start of the script.
  Logger.info(`Starting extension, version ${GM_info.script.version}`);

  /** @type {SwaggerClient} */
  let client;

  // Try to create Swagger client from our own documentation
  try {
    client = await new SwaggerClient({ url: GM_config.get('swaggerDocUrl') });
  } catch (error) {
    Snackbar('We are unable to connect to FR:Reborn, features will be disabled.');
    Logger.error(`We are unable to connect to FR:Reborn: ${GM_config.get('swaggerDocUrl')}`, error);

    return;
  }

  // Get the proper source view, if any
  const agent = getAgent(window.location.hostname);
  if (agent !== null) {
    // Build proper agent and process page
    (await agent.build(client)).process();

    return;
  }

  Sentry.captureMessage(`Unsupported website ${window.location.hostname}`);
  Logger.error('Unsupported website');
}());