Steam Bundle Sites Extension

A steam bundle sites' tool kits.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Steam Bundle Sites Extension
// @homepage     https://github.com/clancy-chao/Steam-Bundle-Sites-Extension
// @namespace    http://tampermonkey.net/
// @version      2.16.6
// @description  A steam bundle sites' tool kits.
// @icon         http://store.steampowered.com/favicon.ico
// @author       Bisumaruko, Cloud
// @include      http*://store.steampowered.com/*
// @include      https://www.indiegala.com/gift*
// @include      https://www.indiegala.com/profile*
// @include      https://www.indiegala.com/library*
// @include      https://www.indiegala.com/game*
// @include      https://www.fanatical.com/*
// @include      https://www.humblebundle.com/*
// @include      http*://*dailyindiegame.com/*
// @include      http*://www.ccyyshop.com/order/*
// @include      https://groupees.com/purchases
// @include      https://groupees.com/profile/purchases/*
// @include      http*://*agiso.com/*
// @include      https://steamdb.keylol.com/tooltip*
// @include      https://yuplay.ru/orders/*/
// @include      https://yuplay.ru/product/*/
// @include      http*://gama-gama.ru/personal/settings/*
// @include      http*://*plati.ru/seller/*
// @include      http*://*plati.market/seller/*
// @include      http*://*plati.ru/cat/*
// @include      http*://*plati.market/cat/*
// @exclude      http*://store.steampowered.com/widget/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/limonte-sweetalert2/7.18.0/sweetalert2.min.js
// @resource     sweetalert2CSS https://cdnjs.cloudflare.com/ajax/libs/limonte-sweetalert2/7.18.0/sweetalert2.min.css
// @resource     currencyFlags https://cdnjs.cloudflare.com/ajax/libs/currency-flags/1.5.0/currency-flags.min.css
// @resource     flagIcon https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/3.1.0/css/flag-icon.min.css
// @connect      store.steampowered.com
// @connect      www.google.com
// @connect      www.google.com.tw
// @connect      www.google.com.au
// @connect      www.google.co.jp
// @connect      www.google.co.nz
// @connect      www.google.co.uk
// @connect      www.google.ca
// @connect      www.google.de
// @connect      www.google.it
// @connect      www.google.fr
// @connect      www.ecb.europa.eu
// @connect      steamdb.keylol.com
// @connect      steamdb.info
// @connect      steamspy.com
// @connect      github.com
// @connect      localhost
// @connect      127.0.0.1
// @connect      *
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

/* global swal */

// inject external css styles
GM_addStyle(GM_getResourceText('sweetalert2CSS'));
GM_addStyle(GM_getResourceText('currencyFlags'));
GM_addStyle(GM_getResourceText('flagIcon').replace(/\.\.\//g, 'https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/3.1.0/'));

// inject script css styles
GM_addStyle(`
  pre.SBSE-errorMsg { height: 200px; text-align: left; white-space: pre-wrap; }
  a.SBSE-link-steam_store, a.SBSE-link-steam_db { text-decoration: none; font-size: smaller; }
  a.SBSE-link-steam_store:hover, a.SBSE-link-steam_db:hover { text-decoration: none; }

  /* switch */
  .SBSE-switch { position: relative; display: inline-block; width: 60px; height: 30px; }
  .SBSE-switch input { display: none; }
  .SBSE-switch__slider {
    position: absolute;
    top: 0; right: 0; bottom: 0; left: 0;
    background-color: #CCC;
    transition: 0.4s;
    cursor: pointer;
  }
  .SBSE-switch__slider:before {
    width: 26px; height: 26px;
    position: absolute;
    bottom: 2px; left: 2px;
    background-color: white;
    transition: 0.4s;
    content: "";
  }
  .SBSE-switch input:checked + .SBSE-switch__slider { background-color: #2196F3; }
  .SBSE-switch input:focus + .SBSE-switch__slider { box-shadow: 0 0 1px #2196F3; }
  .SBSE-switch input:checked + .SBSE-switch__slider:before { transform: translateX(30px); }
  .SBSE-switch--small { width: 40px; height: 20px; }
  .SBSE-switch--small .SBSE-switch__slider:before { width: 16px; height: 16px; }
  .SBSE-switch--small input:checked + .SBSE-switch__slider:before { transform: translateX(20px); }

  /* dropdown */
  .SBSE-dropdown { display: inline-block; position: relative; }
  .SBSE-dropdown__list {
    width: calc(100% - 10px);
    max-height: 0;
    display: inline-block;
    position: absolute;
    top: 35px; left: 0;
    padding: 0;
    transition: all 0.15s;
    overflow: hidden;
    list-style-type: none;
    background-color: #EEE;
    box-shadow: 1px 2px 3px rgba(0,0,0,0.45);
    z-index: 999;
  }
  .SBSE-dropdown__list > li { width: 100%; display: block; padding: 3px 0; text-align: center; }
  .SBSE-dropdown:hover > .SBSE-dropdown__list { max-height: 500px; }

  /* grid */
  .SBSE-grid { display: flex; flex-wrap: wrap; }
  .SBSE-grid > span {
    display: inline-block;
    margin: 2px 10px;
    padding: 0 5px;
    border-radius: 5px;
    cursor: pointer;
  }
  .SBSE-grid > .separator {
    display: block;
    width: 100%;
    margin-top: 12px;
    text-align: left;
    font-weight: bold;
    cursor: default;
  }
  .SBSE-grid > span.selected { background-color: antiquewhite; }

  /* settings */
  .SBSE-container__content__model[data-feature="setting"] .name { text-align: right; vertical-align: top; }
  .SBSE-container__content__model[data-feature="setting"] .value { text-align: left; }
  .SBSE-container__content__model[data-feature="setting"] .value > * { height: 30px; margin: 0 20px 10px; }
  .SBSE-container__content__model[data-feature="setting"] > span { display: inline-block; color: white; cursor: pointer; }

  /* container */
  .SBSE-container { width: 100%; }
  .SBSE-container__nav > ul { display: flex; margin: 0; padding: 0; list-style: none; }
  .SBSE-container__nav__item { flex: 1 1 auto; text-align: center; cursor: pointer; }
  .SBSE-container__nav__item--show { border-bottom: 1px solid #29B6F6; color: #29B6F6; }
  .SBSE-container__nav__item > span { display: block; padding: 10px; }
  .SBSE-container__content__model {
    width: 100%; height: 200px;
    display: flex;
    margin-top: 10px;
    flex-direction: column;
    box-sizing: border-box;
  }
  .SBSE-container__content__model { display: none; }
  .SBSE-container__content__model[data-feature="setting"] { height: 100%; display: block; }
  .SBSE-container__content__model--show { display: block; }
  .SBSE-container__content__model > textarea {
    width: 100%; height: 150px;
    padding: 5px;
    border: none;
    box-sizing: border-box;
    resize: none;
    outline: none;
  }
  .SBSE-container__content__model > div { width: 100%; padding-top: 5px; box-sizing: border-box; }
  .SBSE-button {
    width: 120px;
    position: relative;
    margin-right: 10px;
    line-height: 28px;
    transition: all 0.5s;
    box-sizing: border-box;
    outline: none;
    cursor: pointer;
  }
  .SBSE-select { max-width:120px; height: 30px; }
  .SBSE-container label { margin-right: 10px; }
  .SBSE-dropdown__list-export a { text-decoration: none; color: #333; transition: color 0.3s ease; }
  .SBSE-dropdown__list-export a:hover { text-decoration: none; color: #787878; }
  .SBSE-button-setting {
    width: 20px; height: 20px;
    float: right;
    margin-top: 3px; margin-right: 0; margin-left: 10px;
    background-color: transparent;
    background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGhlaWdodD0iMzJweCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzIgMzI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMYXllcl8xIi8+PGcgaWQ9ImNvZyI+PHBhdGggZD0iTTMyLDE3Ljk2OXYtNGwtNC43ODEtMS45OTJjLTAuMTMzLTAuMzc1LTAuMjczLTAuNzM4LTAuNDQ1LTEuMDk0bDEuOTMtNC44MDVMMjUuODc1LDMuMjUgICBsLTQuNzYyLDEuOTYxYy0wLjM2My0wLjE3Ni0wLjczNC0wLjMyNC0xLjExNy0wLjQ2MUwxNy45NjksMGgtNGwtMS45NzcsNC43MzRjLTAuMzk4LDAuMTQxLTAuNzgxLDAuMjg5LTEuMTYsMC40NjlsLTQuNzU0LTEuOTEgICBMMy4yNSw2LjEyMWwxLjkzOCw0LjcxMUM1LDExLjIxOSw0Ljg0OCwxMS42MTMsNC43MDMsMTIuMDJMMCwxNC4wMzF2NGw0LjcwNywxLjk2MWMwLjE0NSwwLjQwNiwwLjMwMSwwLjgwMSwwLjQ4OCwxLjE4OCAgIGwtMS45MDIsNC43NDJsMi44MjgsMi44MjhsNC43MjMtMS45NDVjMC4zNzksMC4xOCwwLjc2NiwwLjMyNCwxLjE2NCwwLjQ2MUwxNC4wMzEsMzJoNGwxLjk4LTQuNzU4ICAgYzAuMzc5LTAuMTQxLDAuNzU0LTAuMjg5LDEuMTEzLTAuNDYxbDQuNzk3LDEuOTIybDIuODI4LTIuODI4bC0xLjk2OS00Ljc3M2MwLjE2OC0wLjM1OSwwLjMwNS0wLjcyMywwLjQzOC0xLjA5NEwzMiwxNy45Njl6ICAgIE0xNS45NjksMjJjLTMuMzEyLDAtNi0yLjY4OC02LTZzMi42ODgtNiw2LTZzNiwyLjY4OCw2LDZTMTkuMjgxLDIyLDE1Ljk2OSwyMnoiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PC9nPjwvc3ZnPg==);
    background-size: contain;
    background-repeat: no-repeat;
    background-origin: border-box;
    border: none;
    vertical-align: top;
    cursor: pointer;
  }

  /* terminal */
  .SBSE-terminal {
    height: 150px;
    display: none;
    margin: 0;
    padding: 0;
    background-color: #000;
  }
  .SBSE-terminal--show { display: block; }
  .SBSE-terminal > div {
    max-height: 100%;
    display: flex;
    flex-direction: column;
    overflow: auto;
    background-color: transparent;
  }
  .SBSE-terminal > div > span {
    display: inline-block;
    padding-left: 20px;
    color: #FFF;
    text-indent: -20px;
  }
  .SBSE-terminal > div > span:before {
    content: '>';
    width: 20px;
    display: inline-block;
    text-align: center;
    text-indent: 0;
  }
  .SBSE-terminal__message {}
  .SBSE-terminal__input {
    width: 100%;
    position: relative;
    order: 9999;
    box-sizing: border-box;
  }
  .SBSE-terminal__input > input {
    width: inherit;
    max-width: calc(100% - 30px);
    position: absolute;
    top: 0; right: 0; bottom: 0; left: 20px;
    padding: 0;
    border: none;
    outline: none;
    background-color: transparent;
    color: #FFF;
  }
  .SBSE-terminal__input > input:first-child { z-index: 9; }
  .SBSE-terminal__input > input:last-child { z-index: 3; color: gray; }

  /* spinner button affect */
  .SBSE-button:before {
    width: 20px; height: 20px;
    content: '';
    position: absolute;
    margin-top: 5px;
    right: 10px;
    border: 3px solid;
    border-left-color: transparent;
    border-radius: 50%;
    box-sizing: border-box;
    opacity: 0;
    transition: opacity 0.5s;
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotate;
    animation-timing-function: linear;
  }
  .SBSE-button.SBSE-button--narrow.SBSE-button--working {
    width: 100px;
    padding-right: 40px;
    transition: all 0.5s;
  }
  .SBSE-button.SBSE-button--working:before {
    transition-delay: 0.5s;
    transition-duration: 1s;
    opacity: 1;
  }
  @keyframes rotate {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
  }

  /* types */
  .SBSE-type {
    height: 20px;
    display: none;
    margin-right: 5px;
    justify-content: center;
  }
  .SBSE-type:before, .SBSE-type:after {
    content: '';
    box-sizing: border-box;
    pointer-events: none;
  }
  .SBSE-type:after { padding: 0 2px; }
  .SBSE-item--game .SBSE-type { background-color: rgba(97,100,101,0.3); }
  .SBSE-item--game .SBSE-type:after { content: 'Game'; }
  .SBSE-item--DLC .SBSE-type { background-color: rgba(165,84,177,0.8); }
  .SBSE-item--DLC .SBSE-type:before {
    content: 'ꜜ';
    width: 14px; height: 14px;
    margin: 3px 0 0 2px;
    border-radius: 50%;
    background-color: #000;
    color: rgba(165,84,177,0.8);
    text-align: center;
    font-size: 28px;
    line-height: 28px;
  }
  .SBSE-item--DLC .SBSE-type:after { content: 'DLC'; }
  .SBSE-item--package .SBSE-type { background-color: rgba(47,137,188,0.8); }
  .SBSE-item--package .SBSE-type:after { content: 'Package'; }
  .SBSE-item--steam .SBSE-type { display: flex; }

  /* icons */
  .SBSE-icon {
    width: 20px; height: 20px;
    display: none;
    margin-left: 5px;
    border-radius: 50%;
    background-color: #E87A90;
    transform: rotate(45deg);
  }
  .SBSE-icon:before, .SBSE-icon:after {
    content: '';
    width: 3px; height: 14px;
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    background-color: white;
    border-radius: 5px;
    pointer-events: none;
  }
  .SBSE-icon:after { transform: translate(-50%, -50%) rotate(-90deg); }
  .SBSE-item--owned .SBSE-icon { background-color: #9CCC65; }
  .SBSE-item--owned .SBSE-icon:before, .SBSE-item--owned .SBSE-icon:after { transform: none; }
  .SBSE-item--owned .SBSE-icon:before {
    width: 3px; height: 11px;
    top: 4px; left: 10px;
    border-radius: 5px 5px 5px 0;
  }
  .SBSE-item--owned .SBSE-icon:after {
    width: 5px; height: 3px;
    top: 12px; left: 6px;
    border-radius: 5px 0 0 5px;
  }
  .SBSE-item--wished .SBSE-icon { transform: rotate(0); background-color: #29B6F6; }
  .SBSE-item--wished .SBSE-icon:before, .SBSE-item--wished .SBSE-icon:after {
    width: 6px; height: 10px;
    top: 5px; left: 10px;
    border-radius: 6px 6px 0 0;
    transform: rotate(-45deg);
    transform-origin: 0 100%;
  }
  .SBSE-item--wished .SBSE-icon:after {
    left: 4px;
    transform: rotate(45deg);
    transform-origin :100% 100%;
  }
  .SBSE-item--ignored .SBSE-icon { background-color: rgb(135, 173, 189); }
  .SBSE-item--notApplicable .SBSE-icon { transform: rotate(0); background-color: rgb(248, 187, 134); }
  .SBSE-item--notApplicable .SBSE-icon:before {
    content: '?';
    width: 0; height: 10px;
    top: 5px; left: 7px;
    color: white;
    font-size: 16px; font-weight: 900;
  }
  .SBSE-item--notApplicable .SBSE-icon:after { display: none; }
  .SBSE-item--fetching .SBSE-icon { transform: rotate(0); background-color: transparent; }
  .SBSE-item--fetching .SBSE-icon:before {
    width: 20px; height: 20px;
    top: 0; left: 0;
    border: 3px solid grey;
    border-left-color: transparent;
    border-radius: 50%;
    box-sizing: border-box;
    transition: opacity 0.5s;
    animation-duration: 1s;
    animation-iteration-count: infinite;
    animation-name: rotate;
    animation-timing-function: linear;
  }
  .SBSE-item--fetching .SBSE-icon:after { display: none; }
  .SBSE-item--notFetched .SBSE-icon { background-color: transparent; }
  .SBSE-item--notFetched .SBSE-icon:before, .SBSE-item--notFetched .SBSE-icon:after { display: none; }
  .SBSE-item--failed .SBSE-icon { transform: rotate(0); }
  .SBSE-item--failed .SBSE-icon:before {
    content: '!';
    width: 0; height: 10px;
    top: 5px; left: 8.5px;
    color: white;
    font-size: 16px; font-weight: 900;
  }
  .SBSE-item--failed .SBSE-icon:after { display: none; }
  .SBSE-item--steam .SBSE-icon { display: inline-block; }

  /* Steam Tooltip */
  .SBSE-tooltip {
    width: 308px;
    display: none;
    position: fixed;
    overflow: hidden;
    background: url(https://steamstore-a.akamaihd.net/public/images/v6/blue_body_darker_repeat.jpg) -700px center repeat-y scroll rgb(0, 0, 0);
    border: 0;
    box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.5);
    transition: all 0.5s;
    z-index: 999;
  }
  .SBSE-tooltip--show{ display: block; }

  /* Tooltip */
  [tooltip]::before, [tooltip]::after {
    position: absolute;
    opacity: 0;
    transition: all 0.15s ease;
  }
  [tooltip]::before {
    width: max-content;
    content: attr(tooltip);
    top: calc(100% + 10px); left: 0;
    padding: 10px;
    color: #4a4c45;
    background-color: white;
    border-radius: 3px;
    box-shadow: 1px 2px 3px rgba(0,0,0,0.45);
  }
  [tooltip]::after {
    content: "";
    top: calc(100% + 5px); left: 10px;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-bottom: 5px solid white;
  }
  [tooltip]:hover::before, [tooltip]:hover::after { opacity: 1; }
  [tooltip]:not([tooltip-persistent])::before, [tooltip]:not([tooltip-persistent])::after { pointer-events: none; }
`);

// load up
const regURL = /(https?:\/\/)?([.\w]*steam[-.\w]*){1}\/.*?(apps?|subs?){1}\/(\d+){1}(\/.*\/?)?/m;
const regKey = /(?:(?:([A-Z0-9])(?!\1{4})){5}-){2,5}[A-Z0-9]{5}/g;
const eol = "\n";
const tab = "\t";
const has = Object.prototype.hasOwnProperty;
const forEachAsync = (array, callback, lastIterationCallback) => {
  if (!Array.isArray(array)) throw Error('Not an array');
  if (typeof callback !== 'function') throw Error('Not an function');

  const iterators = [...array.keys()];
  const processor = (taskStartTime) => {
    let taskFinishTime;

    do {
      const iterator = iterators.shift();

      if (iterator in array) callback(array[iterator], iterator, array);

      taskFinishTime = window.performance.now();
    } while (taskFinishTime - taskStartTime < 1000 / 60);

    if (iterators.length > 0) requestAnimationFrame(processor);
    // finished iterating array
    else if (typeof lastIterationCallback === 'function') lastIterationCallback();
  };

  requestAnimationFrame(processor);
};
const unique = a => [...new Set(a)];
const isArray = value => Array.isArray(value);
const isObject = value => Object(value) === value;
const request = options => new Promise((resolve, reject) => {
  options.onerror = reject;
  options.ontimeout = reject;
  options.onload = resolve;

  GM_xmlhttpRequest(options);
});

// setup jQuery
const $ = jQuery.noConflict(true);

$.fn.pop = [].pop;
$.fn.shift = [].shift;
$.fn.eachAsync = function eachAsync(callback, lastIterationCallback) {
  forEachAsync(this.get(), callback, lastIterationCallback);
};

const config = {
  data: JSON.parse(GM_getValue('SBSE_config', '{}')),
  set(key, value, callback) {
    this.data[key] = value;
    GM_setValue('SBSE_config', JSON.stringify(this.data));

    if (typeof callback === 'function') callback();
  },
  get(key) {
    return has.call(this.data, key) ? this.data[key] : null;
  },
  init() {
    if (!has.call(this.data, 'autoUpdateSessionID')) this.data.autoUpdateSessionID = true;
    if (!has.call(this.data, 'autoSyncLibrary')) this.data.autoSyncLibrary = true;
    if (!has.call(this.data, 'ASFFormat')) this.data.ASFFormat = false;
    if (!has.call(this.data, 'titleComesLast')) this.data.titleComesLast = false;
    if (!has.call(this.data, 'activateAllKeys')) this.data.activateAllKeys = false;
    if (!has.call(this.data, 'enableTooltips')) this.data.enableTooltips = this.get('language') !== 'english';
    if (!has.call(this.data, 'highlightedRegions')) this.data.highlightedRegions = ['CN', 'HK', 'TW'];
    if (!has.call(this.data, 'enableASFIPC')) this.data.enableASFIPC = false;
    if (!has.call(this.data, 'ASFWSProtocol')) this.data.ASFWSProtocol = 'ws';
    if (!has.call(this.data, 'ASFIPCProtocol')) this.data.ASFIPCProtocol = 'http';
    if (!has.call(this.data, 'ASFIPCServer')) this.data.ASFIPCServer = '127.0.0.1';
    if (!has.call(this.data, 'ASFIPCPort')) this.data.ASFIPCPort = 1242;
    if (!has.call(this.data, 'ASFIPCPassword')) this.data.ASFIPCPassword = '';
  },
};
const i18n = {
  data: {
    tchinese: {
      name: '正體中文',
      updateSuccessTitle: '更新成功!',
      updateSuccess: '成功更新Steam sessionID',
      successStatus: '成功',
      successTitle: '好極了!',
      successDetail: '無資料',
      skippedStatus: '跳過',
      activatedDetail: '已啟動',
      loadingSuccess: '加載完成!',
      failStatus: '失敗',
      failTitle: '糟糕!',
      failDetailUnexpected: '發生未知錯誤,請稍後再試',
      failDetailInvalidKey: '序號錯誤',
      failDetailUsedKey: '序號已被使用',
      failDetailRateLimited: '啟動受限',
      failDetailCountryRestricted: '地區限制',
      failDetailAlreadyOwned: '產品已擁有',
      failDetailMissingBaseGame: '未擁有主程式',
      failDetailPS3Required: '需要PS3 啟動',
      failDetailGiftWallet: '偵測到禮物卡/錢包序號',
      failDetailParsingFailed: '處理資料發生錯誤,請稍後再試',
      failDetailRequestFailedNeedUpdate: '請求發生錯誤,請稍後再試<br/>或者嘗試更新SessionID',
      noItemDetails: '無產品詳細資料',
      notLoggedInTitle: '未登入',
      notLoggedInMsg: '請登入Steam 以讓腳本紀錄SessionID',
      missingTitle: '未發現SessionID',
      missingMsg: '請問要更新SessionID 嗎?',
      emptyInput: '未發現Steam 序號',
      settingsTitle: '設定',
      settingsAutoUpdateSessionID: '自動更新SessionID',
      settingsSessionID: '我的SessionID',
      settingsAutoSyncLibrary: '自動同步Steam 遊戲庫',
      settingsSyncLibrary: '同步遊戲庫',
      settingsSyncLibraryButton: '同步',
      settingsLanguage: '語言',
      settingsASFFormat: '啟用ASF 格式',
      settingsTitleComesLast: '遊戲名置後',
      settingsActivateAllKeys: '不跳過、啟動所有序號',
      settingsEnableTooltips: 'Keylol 論壇提示框',
      settingshighlightedRegions: '標示出地區',
      settingshighlightedRegionsButton: '選擇地區',
      settingsEnableASFIPC: '啟用ASF IPC',
      settingsASFWSProtocol: 'ASF WS 傳輸協定',
      settingsASFIPCProtocol: 'ASF IPC 傳輸協定',
      settingsASFIPCServer: 'ASF IPC IP位址',
      settingsASFIPCPort: 'ASF IPC 連接埠',
      settingsASFIPCPassword: 'ASF IPC 密碼',
      settingsReportIssues: '回報問題/新功能請求',
      HBAlreadyOwned: '遊戲已擁有',
      HBRedeemAlreadyOwned: '確定刮開 %title% Steam 序號?',
      steamStore: 'Steam 商店',
      HBActivationRestrictions: '啟動限制',
      HBDisallowedCountries: '限制以下地區啟動',
      HBExclusiveCountries: '僅限以下地區啟動',
      HBCurrentLocation: '當前位於:',
      DIGMenuPurchase: '購買',
      DIGMenuSelectAll: '全選',
      DIGMenuSelectCancel: '取消',
      DIGButtonPurchasing: '購買中',
      DIGInsufficientFund: '餘額不足',
      DIGFinishedPurchasing: '購買完成',
      DIGMarketSearchResult: '目前市集上架中',
      DIGRateAllPositive: '全部好評',
      DIGClickToHideThisRow: '隱藏此上架遊戲',
      DIGCurrentBalance: '當前餘額:',
      DIGEditBalance: '更新DIG 錢包餘額',
      DIGPoint: 'DIG 點數',
      DIGTotalAmount: '購買總額:',
      buttonReveal: '刮開',
      buttonRetrieve: '提取',
      buttonActivate: '啟動',
      buttonCopy: '複製',
      buttonReset: '清空',
      buttonExport: '匯出',
      buttonCommands: '指令',
      buttonLog: '日誌',
      checkboxIncludeGameTitle: '遊戲名',
      checkboxJoinKeys: '合併',
      checkboxSkipUsed: '跳過已使用',
      checkboxMarketListings: '上架於市集',
      selectFilterAll: '選取全部',
      selectFilterOwned: '選取已擁有',
      selectFilterNotOwned: '選取未擁有',
      selectConnector: '至',
      markAllAsUsed: '標記全部已使用',
      syncSuccessTitle: '同步成功',
      syncSuccess: '成功同步Steam 遊戲庫資料',
      syncFailTitle: '同步失敗',
      syncFail: '失敗同步Steam 遊戲庫資料',
      visitSteam: '前往Steam',
      lastSyncTime: '已於%seconds% 秒前同步收藏庫',
      game: '遊戲',
      dlc: 'DLC',
      package: '合集',
      bundle: '組合包',
      owned: '已擁有',
      wished: '於願望清單',
      ignored: '已忽略',
      notOwned: '未擁有',
      notApplicable: '無資料',
      notFetched: '未檢查',
      enablePlatiFeature: '啟用腳本',
      platiFetchOnStart: '自動檢查',
      platiInfiniteScroll: '自動換頁',
      platiFetchButton: '檢查',
      platiFilterType: '顯示類型',
      platiFilterStatus: '顯示狀態',
    },
    schinese: {
      name: '简体中文',
      updateSuccessTitle: '更新成功',
      updateSuccess: '成功更新Steam sessionID',
      successStatus: '成功',
      successTitle: '好极了!',
      successDetail: '无信息',
      activatedDetail: '已激活',
      loadingSuccess: '加载完成!',
      skippedStatus: '跳过',
      failStatus: '失败',
      failTitle: '糟糕!',
      failDetailUnexpected: '发生未知错误,请稍后再试',
      failDetailInvalidKey: '激活码错误',
      failDetailUsedKey: '激活码已被使用',
      failDetailRateLimited: '激活受限',
      failDetailCountryRestricted: '地区限制',
      failDetailAlreadyOwned: '产品已拥有',
      failDetailMissingBaseGame: '未拥有基础游戏',
      failDetailPS3Required: '需要PS3 激活',
      failDetailGiftWallet: '侦测到礼物卡/钱包激活码',
      failDetailParsingFailed: '处理资料发生错误,请稍后再试',
      failDetailRequestFailedNeedUpdate: '请求发生错误,请稍后再试<br/>或者尝试更新SessionID',
      noItemDetails: '无产品详细信息',
      notLoggedInTitle: '未登入',
      notLoggedInMsg: '请登入Steam 以让脚本记录SessionID',
      missingTitle: '未发现SessionID',
      missingMsg: '请问要更新SessionID 吗?',
      emptyInput: '未批配到Steam 激活码',
      settingsTitle: '设置',
      settingsAutoUpdateSessionID: '自动更新SessionID',
      settingsSessionID: '我的SessionID',
      settingsAutoSyncLibrary: '自动同步Steam 游戏库',
      settingsSyncLibrary: '同步游戏库',
      settingsSyncLibraryButton: '同步',
      settingsLanguage: '语言',
      settingsASFFormat: '启用ASF 格式',
      settingsTitleComesLast: '游戏名置后',
      settingsActivateAllKeys: '不跳过、激活所有激活码',
      settingsEnableTooltips: 'Keylol 论坛提示窗',
      settingshighlightedRegions: '标示出地区',
      settingshighlightedRegionsButton: '选择地区',
      settingsEnableASFIPC: '启用ASF IPC',
      settingsASFWSProtocol: 'ASF WS 传输协议',
      settingsASFIPCProtocol: 'ASF IPC 传输协议',
      settingsASFIPCServer: 'ASF IPC IP地址',
      settingsASFIPCPort: 'ASF IPC 端口',
      settingsASFIPCPassword: 'ASF IPC 密码',
      settingsReportIssues: '回报问题/新功能请求',
      HBAlreadyOwned: '游戏已拥有',
      HBRedeemAlreadyOwned: '确定刮开 %title% Steam 激活码?',
      steamStore: 'Steam 商店',
      HBActivationRestrictions: '激活限制',
      HBDisallowedCountries: '限制以下地区激活',
      HBExclusiveCountries: '仅限以下地区激活',
      HBCurrentLocation: '当前位于:',
      DIGMenuPurchase: '购买',
      DIGMenuSelectAll: '全选',
      DIGMenuSelectCancel: '取消',
      DIGButtonPurchasing: '购买中',
      DIGInsufficientFund: '余额不足',
      DIGFinishedPurchasing: '购买完成',
      DIGMarketSearchResult: '目前市集上架中',
      DIGRateAllPositive: '全部好评',
      DIGClickToHideThisRow: '隐藏此上架游戏',
      DIGCurrentBalance: '当前余额:',
      DIGEditBalance: '更新DIG 錢包餘額',
      DIGPoint: 'DIG 点数',
      DIGTotalAmount: '购买总额:',
      buttonReveal: '刮开',
      buttonRetrieve: '提取',
      buttonActivate: '激活',
      buttonCopy: '复制',
      buttonReset: '清空',
      buttonExport: '导出',
      buttonCommands: '指令',
      buttonLog: '日志',
      checkboxIncludeGameTitle: '游戏名',
      checkboxJoinKeys: '合并',
      checkboxSkipUsed: '跳过已使用',
      checkboxMarketListings: '上架于市集',
      selectFilterAll: '选取全部',
      selectFilterOwned: '选取已拥有',
      selectFilterNotOwned: '选取未拥有',
      selectConnector: '至',
      markAllAsUsed: '标记全部已使用',
      syncSuccessTitle: '同步成功',
      syncSuccess: '成功同步Steam 游戏库资料',
      syncFailTitle: '同步失败',
      syncFail: '失败同步Steam 游戏库资料',
      visitSteam: '前往Steam',
      lastSyncTime: '已于%seconds% 秒前同步游戏库',
      game: '游戏',
      dlc: 'DLC',
      package: '礼包',
      bundle: '捆绑包',
      owned: '已拥有',
      wished: '于愿望清单',
      ignored: '已忽略',
      notOwned: '未拥有',
      notApplicable: '无资料',
      notFetched: '未检查',
      enablePlatiFeature: '启用脚本',
      platiFetchOnStart: '自动检查',
      platiInfiniteScroll: '自动换页',
      platiFetchButton: '检查',
      platiFilterType: '显示类型',
      platiFilterStatus: '显示状态',
    },
    english: {
      name: 'English',
      updateSuccessTitle: 'Update Successful!',
      updateSuccess: 'Steam sessionID is successfully updated',
      successStatus: 'Success',
      successTitle: 'Hurray!',
      successDetail: 'No Detail',
      activatedDetail: 'Activated',
      loadingSuccess: 'Loaded',
      skippedStatus: 'Skipped',
      failStatus: 'Fail',
      failTitle: 'Opps!',
      failDetailUnexpected: 'Unexpected Error',
      failDetailInvalidKey: 'Invalid Key',
      failDetailUsedKey: 'Used Key',
      failDetailRateLimited: 'Rate Limited',
      failDetailCountryRestricted: 'Country Restricted',
      failDetailAlreadyOwned: 'Product Already Owned',
      failDetailMissingBaseGame: 'Missing Base Game',
      failDetailPS3Required: 'PS3 Activation Required',
      failDetailGiftWallet: 'Gift Card/Wallet Code Detected',
      failDetailParsingFailed: 'Result parse failed',
      failDetailRequestFailedNeedUpdate: 'Request failed, please try again<br/>or update sessionID',
      noItemDetails: 'No Item Details',
      notLoggedInTitle: 'Not Logged-In',
      notLoggedInMsg: 'Please login to Steam so sessionID can be saved',
      missingTitle: 'Missing SessionID',
      missingMsg: 'Do you want to update your Steam sessionID?',
      emptyInput: 'Could not find Steam code',
      settingsTitle: 'Settings',
      settingsAutoUpdateSessionID: 'Auto Update SessionID',
      settingsSessionID: 'Your sessionID',
      settingsAutoSyncLibrary: 'Auto Sync Library',
      settingsSyncLibrary: 'Sync Library',
      settingsSyncLibraryButton: 'Sync',
      settingsLanguage: 'Language',
      settingsASFFormat: 'Enable ASF Format',
      settingsTitleComesLast: 'Title Comes Last',
      settingsActivateAllKeys: 'No skip & activate all keys',
      settingsEnableTooltips: 'Tooltips from Keylol',
      settingshighlightedRegions: 'Highlighted Regions',
      settingshighlightedRegionsButton: 'Select Regions',
      settingsEnableASFIPC: 'Enable ASF IPC',
      settingsASFWSProtocol: 'ASF WS Protocol',
      settingsASFIPCProtocol: 'ASF IPC Protocol',
      settingsASFIPCServer: 'ASF IPC IP Address',
      settingsASFIPCPort: 'ASF IPC Port',
      settingsASFIPCPassword: 'ASF IPC Password',
      settingsReportIssues: 'Report Issues or Request Features',
      HBAlreadyOwned: 'Game Already Owned',
      HBRedeemAlreadyOwned: 'Are you sure to redeem %title% Steam Key?',
      steamStore: 'Steam Store',
      HBActivationRestrictions: 'Activation Restrictions',
      HBDisallowedCountries: 'Cannot be activated in the following regions',
      HBExclusiveCountries: 'Can only be activated in the following regions',
      HBCurrentLocation: 'Current Location: ',
      DIGMenuPurchase: 'Purchase',
      DIGMenuSelectAll: 'Select All',
      DIGMenuSelectCancel: 'Cancel',
      DIGButtonPurchasing: 'Purchassing',
      DIGInsufficientFund: 'Insufficient fund',
      DIGFinishedPurchasing: 'Finished Purchasing',
      DIGMarketSearchResult: 'Currently listing in marketplace',
      DIGRateAllPositive: 'Mark All Positive',
      DIGClickToHideThisRow: 'Hide this game from listings',
      DIGCurrentBalance: 'Current Balance: ',
      DIGEditBalance: 'Edit DIG balance',
      DIGPoint: 'DIG Point',
      DIGTotalAmount: 'Total Amount: ',
      buttonReveal: 'Reveal',
      buttonRetrieve: 'Retrieve',
      buttonActivate: 'Activate',
      buttonCopy: 'Copy',
      buttonReset: 'Reset',
      buttonExport: 'Export',
      buttonCommands: 'Commands',
      buttonLog: 'Log',
      checkboxIncludeGameTitle: 'Game Title',
      checkboxJoinKeys: 'Join',
      checkboxSkipUsed: 'Skip Used',
      checkboxMarketListings: 'Market Listings',
      selectFilterAll: 'Select All',
      selectFilterOwned: 'Select Owned',
      selectFilterNotOwned: 'Select Not Owned',
      selectConnector: 'to',
      markAllAsUsed: 'Mark All as Used',
      syncSuccessTitle: 'Sync Successful',
      syncSuccess: 'Successfully sync Steam library data',
      syncFailTitle: 'Sync failed',
      syncFail: 'Failed to sync Steam library data',
      visitSteam: 'Visit Steam',
      lastSyncTime: 'Library data synced %seconds% seconds ago',
      game: 'Game',
      dlc: 'DLC',
      package: 'Package',
      bundle: 'Bundle',
      owned: 'Owned',
      wished: 'Wishlisted',
      ignored: 'Ignored',
      notOwned: 'Not Owned',
      notApplicable: 'Not Applicable',
      notFetched: 'Not Checked',
      enablePlatiFeature: 'Enable Script',
      platiFetchOnStart: 'Auto Check',
      platiInfiniteScroll: 'Infinite Scroll',
      platiFetchButton: 'Check',
      platiFilterType: 'Show Type',
      platiFilterStatus: 'Show Status',
    },
  },
  language: null,
  set() {
    const selectedLanguage = has.call(this.data, config.get('language')) ? config.get('language') : 'english';

    this.language = this.data[selectedLanguage];
  },
  get(key) {
    return has.call(this.language, key) ? this.language[key] : this.data.english[key];
  },
  init() {
    this.set();
  },
};
const ISO2 = {
  name: {
    tchinese: {
      AD: '安道爾',
      AE: '阿拉伯聯合大公國',
      AF: '阿富汗',
      AG: '安地卡及巴布達',
      AI: '安圭拉',
      AL: '阿爾巴尼亞',
      AM: '亞美尼亞',
      AO: '安哥拉',
      AQ: '南極洲',
      AR: '阿根廷',
      AS: '美屬薩摩亞',
      AT: '奧地利',
      AU: '澳大利亞',
      AW: '阿魯巴',
      AX: '奧蘭',
      AZ: '亞塞拜然',
      BA: '波士尼亞與赫塞哥維納',
      BB: '巴貝多',
      BD: '孟加拉',
      BE: '比利時',
      BF: '布吉納法索',
      BG: '保加利亞',
      BH: '巴林',
      BI: '蒲隆地',
      BJ: '貝南',
      BL: '聖巴泰勒米',
      BM: '百慕達',
      BN: '汶萊',
      BO: '玻利維亞',
      BQ: '波奈',
      BR: '巴西',
      BS: '巴哈馬',
      BT: '不丹',
      BV: '布威島',
      BW: '波札那',
      BY: '白俄羅斯',
      BZ: '貝里斯',
      CA: '加拿大',
      CC: '科科斯(基林)群島',
      CD: '剛果民主共和國',
      CF: '中非共和國',
      CG: '剛果共和國',
      CH: '瑞士',
      CI: '象牙海岸',
      CK: '庫克群島',
      CL: '智利',
      CM: '喀麥隆',
      CN: '中國',
      CO: '哥倫比亞',
      CR: '哥斯大黎加',
      CS: '塞爾維亞與蒙特內哥羅',
      CU: '古巴',
      CV: '維德角',
      CW: '古拉索',
      CX: '聖誕島',
      CY: '賽普勒斯',
      CZ: '捷克',
      DE: '德國',
      DJ: '吉布地',
      DK: '丹麥',
      DM: '多米尼克',
      DO: '多明尼加',
      DZ: '阿爾及利亞',
      EC: '厄瓜多',
      EE: '愛沙尼亞',
      EG: '埃及',
      EH: '西撒哈拉',
      ER: '厄利垂亞',
      ES: '西班牙',
      ET: '衣索比亞',
      FI: '芬蘭',
      FJ: '斐濟',
      FK: '福克蘭群島',
      FM: '密克羅尼西亞聯邦',
      FO: '法羅群島',
      FR: '法國',
      GA: '加彭',
      GB: '英國',
      GD: '格瑞那達',
      GE: '喬治亞',
      GF: '法屬圭亞那',
      GG: '根西',
      GH: '迦納',
      GI: '直布羅陀',
      GL: '格陵蘭',
      GM: '甘比亞',
      GN: '幾內亞',
      GP: '瓜德羅普',
      GQ: '赤道幾內亞',
      GR: '希臘',
      GS: '南喬治亞與南桑威奇',
      GT: '瓜地馬拉',
      GU: '關島',
      GW: '幾內亞比索',
      GY: '蓋亞那',
      HK: '香港',
      HM: '赫德島和麥克唐納群島',
      HN: '宏都拉斯',
      HR: '克羅埃西亞',
      HT: '海地',
      HU: '匈牙利',
      ID: '印尼',
      IE: '愛爾蘭',
      IL: '以色列',
      IM: '曼島',
      IN: '印度',
      IO: '英屬印度洋領地',
      IQ: '伊拉克',
      IR: '伊朗',
      IS: '冰島',
      IT: '義大利',
      JE: '澤西',
      JM: '牙買加',
      JO: '約旦',
      JP: '日本',
      KE: '肯亞',
      KG: '吉爾吉斯',
      KH: '柬埔寨',
      KI: '吉里巴斯',
      KM: '葛摩',
      KN: '聖克里斯多福及尼維斯',
      KP: '北韓',
      KR: '南韓',
      KW: '科威特',
      KY: '開曼群島',
      KZ: '哈薩克',
      LA: '寮國',
      LB: '黎巴嫩',
      LC: '聖露西亞',
      LI: '列支敦斯登',
      LK: '斯里蘭卡',
      LR: '賴比瑞亞',
      LS: '賴索托',
      LT: '立陶宛',
      LU: '盧森堡',
      LV: '拉脫維亞',
      LY: '利比亞',
      MA: '摩洛哥',
      MC: '摩納哥',
      MD: '摩爾多瓦',
      ME: '蒙特內哥羅',
      MF: '法屬聖馬丁',
      MG: '馬達加斯加',
      MH: '馬紹爾群島',
      MK: '馬其頓共和國',
      ML: '馬利',
      MM: '緬甸',
      MN: '蒙古',
      MO: '澳門',
      MP: '北馬里亞納群島',
      MQ: '馬丁尼克',
      MR: '茅利塔尼亞',
      MS: '蒙哲臘',
      MT: '馬爾他',
      MU: '模里西斯',
      MV: '馬爾地夫',
      MW: '馬拉威',
      MX: '墨西哥',
      MY: '馬來西亞',
      MZ: '莫三比克',
      NA: '納米比亞',
      NC: '新喀里多尼亞',
      NE: '尼日',
      NF: '諾福克島',
      NG: '奈及利亞',
      NI: '尼加拉瓜',
      NL: '荷蘭',
      NO: '挪威',
      NP: '尼泊爾',
      NR: '諾魯',
      NU: '紐埃',
      NZ: '紐西蘭',
      OM: '阿曼',
      PA: '巴拿馬',
      PE: '秘魯',
      PF: '法屬玻里尼西亞',
      PG: '巴布亞紐幾內亞',
      PH: '菲律賓',
      PK: '巴基斯坦',
      PL: '波瀾',
      PM: '聖皮耶與密克隆群島',
      PN: '皮特肯群島',
      PR: '波多黎各',
      PS: '巴勒斯坦',
      PT: '葡萄牙',
      PW: '帛琉',
      PY: '巴拉圭',
      QA: '卡達',
      RE: '留尼旺',
      RO: '羅馬尼亞',
      RS: '塞爾維亞',
      RU: '俄羅斯',
      RW: '盧安達',
      SA: '沙烏地阿拉伯',
      SB: '索羅門群島',
      SC: '塞席爾',
      SD: '蘇丹',
      SE: '瑞典',
      SG: '新加坡',
      SH: '聖赫勒拿、亞森欣與垂斯坦昆哈',
      SI: '斯洛維尼亞',
      SJ: '斯瓦巴和揚馬延',
      SK: '斯洛伐克',
      SL: '獅子山共和國',
      SM: '聖馬利諾',
      SN: '塞內加爾',
      SO: '索馬利亞',
      SR: '蘇利南',
      SS: '南蘇丹',
      ST: '聖多美普林西比',
      SV: '薩爾瓦多',
      SX: '荷屬聖馬丁',
      SY: '敘利亞',
      SZ: '史瓦濟蘭',
      TC: '土克凱可群島',
      TD: '查德',
      TF: '法屬南部和南極領地',
      TG: '多哥',
      TH: '泰國',
      TJ: '塔吉克',
      TK: '托克勞',
      TL: '東帝汶',
      TM: '土庫曼',
      TN: '突尼西亞',
      TO: '東加',
      TR: '土耳其',
      TT: '千里達及托巴哥',
      TV: '吐瓦魯',
      TW: '臺灣',
      TZ: '坦尚尼亞',
      UA: '烏克蘭',
      UG: '烏干達',
      UM: '美國本土外小島嶼',
      US: '美國',
      UY: '烏拉圭',
      UZ: '烏茲別克',
      VA: '聖座',
      VC: '聖文森及格瑞那丁',
      VE: '委內瑞拉',
      VG: '英屬維京群島',
      VI: '美屬維京群島',
      VN: '越南',
      VU: '萬那杜',
      WF: '瓦利斯和富圖納',
      WS: '薩摩亞',
      XK: '科索沃',
      YE: '葉門',
      YT: '馬約特',
      ZA: '南非',
      ZM: '尚比亞',
      ZW: '辛巴威',
    },
    schinese: {
      AD: '安道尔',
      AE: '阿拉伯联合酋长国',
      AF: '阿富汗',
      AG: '安提瓜和巴布达',
      AI: '安圭拉',
      AL: '阿尔巴尼亚',
      AM: '亚美尼亚',
      AO: '安哥拉',
      AQ: '南极洲',
      AR: '阿根廷',
      AS: '美属萨摩亚',
      AT: '奥地利',
      AU: '澳大利亚',
      AW: '阿鲁巴',
      AX: '奥兰群岛',
      AZ: '阿塞拜疆',
      BA: '波斯尼亚和黑塞哥维那',
      BB: '巴巴多斯',
      BD: '孟加拉',
      BE: '比利时',
      BF: '布基纳法索',
      BG: '保加利亚',
      BH: '巴林',
      BI: '布隆迪',
      BJ: '贝宁',
      BL: '圣巴托洛缪岛',
      BM: '百慕大',
      BN: '文莱',
      BO: '玻利维亚',
      BQ: '博奈尔',
      BR: '巴西',
      BS: '巴哈马',
      BT: '不丹',
      BV: '布韦岛',
      BW: '博兹瓦纳',
      BY: '白俄罗斯',
      BZ: '伯利兹',
      CA: '加拿大',
      CC: '科科斯(基林)群岛',
      CD: '刚果(金)',
      CF: '中非共和国',
      CG: '刚果(布)',
      CH: '瑞士',
      CI: '科特迪瓦',
      CK: '库克群岛',
      CL: '智利',
      CM: '喀麦隆',
      CN: '中国',
      CO: '哥伦比亚',
      CR: '哥斯达黎加',
      CS: '塞尔维亚和黑山',
      CU: '古巴',
      CV: '佛得角',
      CW: '库拉索',
      CX: '圣诞岛',
      CY: '塞浦路斯',
      CZ: '捷克',
      DE: '德国',
      DJ: '吉布提',
      DK: '丹麦',
      DM: '多米尼克',
      DO: '多米尼加',
      DZ: '阿尔及利亚',
      EC: '厄瓜多尔',
      EE: '爱沙尼亚',
      EG: '埃及',
      EH: '西撒哈拉',
      ER: '厄立特里亚',
      ES: '西班牙',
      ET: '埃塞俄比亚',
      FI: '芬兰',
      FJ: '斐济',
      FK: '福克兰群岛',
      FM: '密克罗尼西亚',
      FO: '法罗群岛',
      FR: '法国',
      GA: '加蓬',
      GB: '英国',
      GD: '格林纳达',
      GE: '格鲁吉亚',
      GF: '法属圭亚那',
      GG: '根西',
      GH: '加纳',
      GI: '直布罗陀',
      GL: '格陵兰',
      GM: '冈比亚',
      GN: '几内亚',
      GP: '瓜德鲁普',
      GQ: '赤道几内亚',
      GR: '希腊',
      GS: '南乔治亚岛和南桑威奇群岛',
      GT: '危地马拉',
      GU: '关岛',
      GW: '几内亚比绍',
      GY: '圭亚那',
      HK: '香港',
      HM: '赫德岛和麦克唐纳群岛',
      HN: '洪都拉斯',
      HR: '克罗地亚',
      HT: '海地',
      HU: '匈牙利',
      ID: '印尼',
      IE: '爱尔兰',
      IL: '以色列',
      IM: '马恩岛',
      IN: '印度',
      IO: '英属印度洋领地',
      IQ: '伊拉克',
      IR: '伊朗',
      IS: '冰岛',
      IT: '意大利',
      JE: '泽西岛',
      JM: '牙买加',
      JO: '约旦',
      JP: '日本',
      KE: '肯尼亚',
      KG: '吉尔吉斯',
      KH: '柬埔寨',
      KI: '基里巴斯',
      KM: '科摩罗',
      KN: '圣基茨和尼维斯',
      KP: '朝鲜',
      KR: '韩国',
      KW: '科威特',
      KY: '开曼群岛',
      KZ: '哈萨克斯坦',
      LA: '老挝',
      LB: '黎巴嫩',
      LC: '圣卢西亚',
      LI: '列支敦士登',
      LK: '斯里兰卡',
      LR: '利比里亚',
      LS: '莱索托',
      LT: '立陶宛',
      LU: '卢森堡',
      LV: '拉脱维亚',
      LY: '利比亚',
      MA: '摩洛哥',
      MC: '摩纳哥',
      MD: '摩尔多瓦',
      ME: '黑山',
      MF: '法属圣马丁',
      MG: '马达加斯加',
      MH: '马绍尔群岛',
      MK: '马其顿',
      ML: '马里',
      MM: '缅甸',
      MN: '蒙古',
      MO: '澳门',
      MP: '北马里亚纳群岛',
      MQ: '马提尼克',
      MR: '毛里塔尼亚',
      MS: '蒙塞拉特',
      MT: '马耳他',
      MU: '毛里求斯',
      MV: '马尔代夫',
      MW: '马拉维',
      MX: '墨西哥',
      MY: '马来西亚',
      MZ: '莫桑比克',
      NA: '纳米比亚',
      NC: '新喀里多尼亚',
      NE: '尼日尔',
      NF: '诺福克岛',
      NG: '尼日利',
      NI: '尼加拉瓜',
      NL: '荷兰',
      NO: '挪威',
      NP: '尼泊尔',
      NR: '瑙鲁',
      NU: '纽埃',
      NZ: '新西兰',
      OM: '阿曼',
      PA: '巴拿马',
      PE: '秘鲁',
      PF: '法属波利尼西亚a',
      PG: '巴布亚新几内亚',
      PH: '菲律宾',
      PK: '巴基斯坦',
      PL: '波兰',
      PM: '圣皮埃尔和密克隆',
      PN: '皮特凯恩群岛',
      PR: '波多黎各',
      PS: '巴勒斯坦',
      PT: '葡萄牙',
      PW: '帕劳',
      PY: '巴拉圭',
      QA: '卡塔尔',
      RE: '留尼旺島',
      RO: '罗马尼亚',
      RS: '塞尔维亚',
      RU: '俄罗斯',
      RW: '卢旺达',
      SA: '沙特阿拉伯',
      SB: '所罗门群岛',
      SC: '塞舌尔',
      SD: '苏丹',
      SE: '瑞典',
      SG: '新加坡',
      SH: '圣赫勒拿、阿森松与特斯坦达库尼亚',
      SI: '斯洛文尼',
      SJ: '斯瓦尔巴群岛和扬马延岛',
      SK: '斯洛伐克',
      SL: '塞拉利昂',
      SM: '圣马力诺',
      SN: '塞内加尔',
      SO: '索马里',
      SR: '苏里南',
      SS: '南苏丹',
      ST: '圣多美和普林西比',
      SV: '萨尔瓦多',
      SX: '荷属圣马丁',
      SY: '叙利亚',
      SZ: '斯威士兰',
      TC: '特克斯和凯科斯群岛',
      TD: '乍得',
      TF: '法属南部领土',
      TG: '多哥',
      TH: '泰国',
      TJ: '塔吉克斯坦',
      TK: '托克劳',
      TL: '东帝汶',
      TM: '土库曼斯坦',
      TN: '突尼斯',
      TO: '汤加',
      TR: '土耳其',
      TT: '特立尼达和多巴哥',
      TV: '图瓦卢',
      TW: '台湾',
      TZ: '坦桑尼亚',
      UA: '乌克兰',
      UG: '乌干达',
      UM: '美国本土外小岛屿',
      US: '美国',
      UY: '乌拉圭',
      UZ: '乌兹别克斯坦',
      VA: '圣座',
      VC: '圣文森特和格林纳丁斯',
      VE: '委内瑞拉',
      VG: '英属维尔京群岛',
      VI: '美属维尔京群岛',
      VN: '越南',
      VU: '瓦努阿图',
      WF: '瓦利斯和富图纳群岛',
      WS: '萨摩亚',
      XK: '科索沃',
      YE: '也门',
      YT: '马约特',
      ZA: '南非',
      ZM: '赞比亚',
      ZW: '津巴布韦',
    },
    english: {
      AD: 'Andorra',
      AE: 'United Arab Emirates',
      AF: 'Afghanistan',
      AG: 'Antigua and Barbuda',
      AI: 'Anguilla',
      AL: 'Albania',
      AM: 'Armenia',
      AO: 'Angola',
      AQ: 'Antarctica',
      AR: 'Argentina',
      AS: 'American Samoa',
      AT: 'Austria',
      AU: 'Australia',
      AW: 'Aruba',
      AX: 'Aland Islands',
      AZ: 'Azerbaijan',
      BA: 'Bosnia and Herzegovina',
      BB: 'Barbados',
      BD: 'Bangladesh',
      BE: 'Belgium',
      BF: 'Burkina Faso',
      BG: 'Bulgaria',
      BH: 'Bahrain',
      BI: 'Burundi',
      BJ: 'Benin',
      BL: 'Saint Barthélemy',
      BM: 'Bermuda',
      BN: 'Brunei',
      BO: 'Bolivia',
      BQ: 'Bonaire',
      BR: 'Brazil',
      BS: 'Bahamas',
      BT: 'Bhutan',
      BV: 'Bouvet Island',
      BW: 'Botswana',
      BY: 'Belarus',
      BZ: 'Belize',
      CA: 'Canada',
      CC: 'Cocos (Keeling) Islands',
      CD: 'East Congo',
      CF: 'Central African Republic',
      CG: 'West Congo',
      CH: 'Switzerland',
      CI: 'Ivory Coast',
      CK: 'Cook Islands',
      CL: 'Chile',
      CM: 'Cameroon',
      CN: 'China',
      CO: 'Colombia',
      CR: 'Costa Rica',
      CS: 'Serbia and Montenegro',
      CU: 'Cuba',
      CV: 'Cabo Verde',
      CW: 'Curaçao',
      CX: 'Christmas Island',
      CY: 'Cyprus',
      CZ: 'Czechia',
      DE: 'Germany',
      DJ: 'Djibouti',
      DK: 'Denmark',
      DM: 'Dominica',
      DO: 'Dominican Republic',
      DZ: 'Algeria',
      EC: 'Ecuador',
      EE: 'Estonia',
      EG: 'Egypt',
      EH: 'Western Sahara',
      ER: 'Eritrea',
      ES: 'Spain',
      ET: 'Ethiopia',
      FI: 'Finland',
      FJ: 'Fiji',
      FK: 'Falkland Islands',
      FM: 'Micronesia',
      FO: 'Faroe Islands',
      FR: 'France',
      GA: 'Gabon',
      GB: 'United Kingdom',
      GD: 'Grenada',
      GE: 'Georgia',
      GF: 'French Guiana',
      GG: 'Guernsey',
      GH: 'Ghana',
      GI: 'Gibraltar',
      GL: 'Greenland',
      GM: 'Gambia',
      GN: 'Guinea',
      GP: 'Guadeloupe',
      GQ: 'Equatorial Guinea',
      GR: 'Greece',
      GS: 'South Georgia and the South Sandwich Islands',
      GT: 'Guatemala',
      GU: 'Guam',
      GW: 'Guinea-Bissau',
      GY: 'Guyana',
      HK: 'Hong Kong',
      HM: 'Heard Island and McDonald Islands',
      HN: 'Honduras',
      HR: 'Croatia',
      HT: 'Haiti',
      HU: 'Hungary',
      ID: 'Indonesia',
      IE: 'Ireland',
      IL: 'Israel',
      IM: 'Isle of Man',
      IN: 'India',
      IO: 'British Indian Ocean Territory',
      IQ: 'Iraq',
      IR: 'Iran',
      IS: 'Iceland',
      IT: 'Italy',
      JE: 'Jersey',
      JM: 'Jamaica',
      JO: 'Jordan',
      JP: 'Japan',
      KE: 'Kenya',
      KG: 'Kyrgyzstan',
      KH: 'Cambodia',
      KI: 'Kiribati',
      KM: 'Comoros',
      KN: 'Saint Kitts and Nevis',
      KP: 'North Korea',
      KR: 'South Korea',
      KW: 'Kuwait',
      KY: 'Cayman Islands',
      KZ: 'Kazakhstan',
      LA: 'Lao',
      LB: 'Lebanon',
      LC: 'Saint Lucia',
      LI: 'Liechtenstein',
      LK: 'Sri Lanka',
      LR: 'Liberia',
      LS: 'Lesotho',
      LT: 'Lithuania',
      LU: 'Luxembourg',
      LV: 'Latvia',
      LY: 'Libya',
      MA: 'Morocco',
      MC: 'Monaco',
      MD: 'Moldova',
      ME: 'Montenegro',
      MF: 'Saint Martin (French part)',
      MG: 'Madagascar',
      MH: 'Marshall Islands',
      MK: 'Macedonia',
      ML: 'Mali',
      MM: 'Myanmar',
      MN: 'Mongolia',
      MO: 'Macao',
      MP: 'Northern Mariana Islands',
      MQ: 'Martinique',
      MR: 'Mauritania',
      MS: 'Montserrat',
      MT: 'Malta',
      MU: 'Mauritius',
      MV: 'Maldives',
      MW: 'Malawi',
      MX: 'Mexico',
      MY: 'Malaysia',
      MZ: 'Mozambique',
      NA: 'Namibia',
      NC: 'New Caledonia',
      NE: 'Niger',
      NF: 'Norfolk Island',
      NG: 'Nigeria',
      NI: 'Nicaragua',
      NL: 'Netherlands',
      NO: 'Norway',
      NP: 'Nepal',
      NR: 'Nauru',
      NU: 'Niue',
      NZ: 'New Zealand',
      OM: 'Oman',
      PA: 'Panama',
      PE: 'Peru',
      PF: 'French Polynesia',
      PG: 'Papua New Guinea',
      PH: 'Philippines',
      PK: 'Pakistan',
      PL: 'Poland',
      PM: 'Saint Pierre and Miquelon',
      PN: 'Pitcairn',
      PR: 'Puerto Rico',
      PS: 'Palestine',
      PT: 'Portugal',
      PW: 'Palau',
      PY: 'Paraguay',
      QA: 'Qatar',
      RE: 'Reunion',
      RO: 'Romania',
      RS: 'Serbia',
      RU: 'Russia',
      RW: 'Rwanda',
      SA: 'Saudi Arabia',
      SB: 'Solomon Islands',
      SC: 'Seychelles',
      SD: 'Sudan',
      SE: 'Sweden',
      SG: 'Singapore',
      SH: 'Saint Helena, Ascension and Tristan da Cunha',
      SI: 'Slovenia',
      SJ: 'Svalbard and Jan Mayen',
      SK: 'Slovakia',
      SL: 'Sierra Leone',
      SM: 'San Marino',
      SN: 'Senegal',
      SO: 'Somalia',
      SR: 'Suriname',
      SS: 'South Sudan',
      ST: 'Sao Tome and Principe',
      SV: 'El Salvador',
      SX: 'Sint Maarten (Dutch part)',
      SY: 'Syria',
      SZ: 'Swaziland',
      TC: 'Turks and Caicos Islands',
      TD: 'Chad',
      TF: 'French Southern Territories',
      TG: 'Togo',
      TH: 'Thailand',
      TJ: 'Tajikistan',
      TK: 'Tokelau',
      TL: 'Timor-Leste',
      TM: 'Turkmenistan',
      TN: 'Tunisia',
      TO: 'Tonga',
      TR: 'Turkey',
      TT: 'Trinidad and Tobago',
      TV: 'Tuvalu',
      TW: 'Taiwan',
      TZ: 'Tanzania',
      UA: 'Ukraine',
      UG: 'Uganda',
      UM: 'United States Minor Outlying Islands',
      US: 'United States',
      UY: 'Uruguay',
      UZ: 'Uzbekistan',
      VA: 'Holy See',
      VC: 'Saint Vincent and the Grenadines',
      VE: 'Venezuela',
      VG: 'Virgin Islands, British',
      VI: 'Virgin Islands, U.S.',
      VN: 'Viet Nam',
      VU: 'Vanuatu',
      WF: 'Wallis and Futuna',
      WS: 'Samoa',
      XK: 'Kosovo',
      YE: 'Yemen',
      YT: 'Mayotte',
      ZA: 'South Africa',
      ZM: 'Zambia',
      ZW: 'Zimbabwe',
    },
  },
  get(code, language) {
    const data = this.name[(language || config.get('language') || 'english')];

    return has.call(data, code) ? data[code] : code;
  },
};
const xe = {
  exchangeRate: JSON.parse(GM_getValue('SBSE_xe', '{}')),
  currencies: {
    AUD: {
      english: 'Australian Dollar',
      tchinese: '澳幣',
      schinese: '澳元',
      symbol: 'AU$',
      decimal: true,
    },
    CAD: {
      english: 'Canadian Dollar',
      tchinese: '加幣',
      schinese: '加元',
      symbol: 'CA$',
      decimal: true,
    },
    CNY: {
      english: 'Chinese Yuan',
      tchinese: '人民幣',
      schinese: '人民币',
      symbol: 'CN¥',
      decimal: true,
    },
    EUR: {
      english: 'Euro',
      tchinese: '歐元',
      schinese: '欧元',
      symbol: '€',
      decimal: true,
    },
    GBP: {
      english: 'Great Britain Pound',
      tchinese: '英鎊',
      schinese: '英镑',
      symbol: '£',
      decimal: true,
    },
    HKD: {
      english: 'Hong Kong Dollar',
      tchinese: '港幣',
      schinese: '港元',
      symbol: 'HK$',
      decimal: false,
    },
    JPY: {
      english: 'Japanese Yen',
      tchinese: '日圓',
      schinese: '日元',
      symbol: 'JP¥',
      decimal: false,
    },
    KRW: {
      english: 'South Korean Won',
      tchinese: '韓圓',
      schinese: '韩币',
      symbol: '₩',
      decimal: false,
    },
    MYR: {
      english: 'Malaysian Ringgit',
      tchinese: '令吉',
      schinese: '林吉特',
      symbol: 'RM',
      decimal: true,
    },
    NTD: {
      english: 'New Taiwan Dollar',
      tchinese: '台幣',
      schinese: '台币',
      symbol: 'NT$',
      decimal: false,
    },
    NZD: {
      english: 'New Zealand Dollar',
      tchinese: '紐幣',
      schinese: '新西兰元',
      symbol: 'NZ$',
      decimal: true,
    },
    RUB: {
      english: 'Russian Ruble',
      tchinese: '盧布',
      schinese: '卢布',
      symbol: '₽',
      decimal: false,
    },
    USD: {
      english: 'United States Dollar',
      tchinese: '美元',
      schinese: '美元',
      symbol: 'US$',
      decimal: true,
    },
  },
  getRate() {
    const self = this;

    GM_xmlhttpRequest({
      method: 'GET',
      url: 'http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml',
      onload: (res) => {
        if (res.status === 200) {
          try {
            const exchangeRate = {
              lastUpdate: Date.now(),
              rates: {},
            };

            res.response.split(eol).forEach((line) => {
              if (line.includes('currency=')) {
                const currency = line.split('currency=\'').pop().slice(0, 3);
                const rate = line.trim().split('rate=\'').pop().slice(0, -3);

                exchangeRate.rates[currency] = parseFloat(rate);
              }
            });
            exchangeRate.rates.EUR = 1;

            // get NTD
            GM_xmlhttpRequest({
              method: 'GET',
              url: 'https://www.google.com/search?q=1+EUR+%3D+NTD',
              onload: (searchRes) => {
                const rate = parseFloat(searchRes.response.split('<div class="vk_ans vk_bk">').pop().slice(0, 7).trim());
                const NTDRate = isNaN(rate) ? exchangeRate.rates.HKD * 3.75 : rate;

                exchangeRate.rates.NTD = NTDRate;
                self.exchangeRate = exchangeRate;
                GM_setValue('SBSE_xe', JSON.stringify(exchangeRate));
              },
            });

            // get UAH
            GM_xmlhttpRequest({
              method: 'GET',
              url: 'https://www.google.com/search?q=1+EUR+%3D+UAH',
              onload: (searchRes) => {
                const rate = parseFloat(searchRes.response.split('<div class="vk_ans vk_bk">').pop().slice(0, 7).trim());
                const UAHRate = isNaN(rate) ? 32.85 : rate;

                exchangeRate.rates.UAH = UAHRate;
                self.exchangeRate = exchangeRate;
                GM_setValue('SBSE_xe', JSON.stringify(exchangeRate));
              },
            });
          } catch (e) {
            swal(
              'Parsing Failed',
              'An error occured when parsing exchange rate data, please reload to try again',
              'error',
            );
          }
        } else {
          swal(
            'Loading Failed',
            'Unable to fetch exchange rate data, please reload to try again',
            'error',
          );
        }
      },
    });
  },
  update(targetCurrency = 'USD') {
    $('.SBSE-price').each((i, ele) => {
      const originalCurrency = ele.dataset.currency;
      const originalValue = parseInt(ele.dataset.value, 10);
      const originalRate = this.exchangeRate.rates[originalCurrency];
      const targetRate = this.exchangeRate.rates[targetCurrency];
      const exchangedValue = Math.trunc((originalValue / originalRate) * targetRate);
      const symbol = this.currencies[targetCurrency].symbol;
      const decimalPlace = this.currencies[targetCurrency].decimal ? 2 : 0;

      $(ele).text(symbol + (exchangedValue / 100).toFixed(decimalPlace));
    });
    GM_setValue('SBSE_selectedCurrency', targetCurrency);
  },
  init() {
    const updateTimer = 12 * 60 * 60 * 1000; // update every 12 hours
    const newRate = ['UAH'];

    if (Object.keys(this.exchangeRate).length === 0 ||
      this.exchangeRate.lastUpdate < (Date.now() - updateTimer) ||
      newRate.filter(x => !has.call(this.exchangeRate.rates, x)).length > 0
    ) this.getRate();
  },
};
const steam = {
  library: JSON.parse(GM_getValue('SBSE_steam_library', '{}')),
  games: JSON.parse(GM_getValue('SBSE_steam_games', '{}')),
  getSessionID: async () => {
    const res = await request({
      method: 'GET',
      url: 'https://store.steampowered.com/',
    });

    if (res.status === 200) {
      const accountID = res.response.match(/g_AccountID = (\d+)/).pop();
      const sessionID = res.response.match(/g_sessionID = "(\w+)"/).pop();

      if (accountID > 0) config.set('sessionID', sessionID);
      else {
        swal({
          title: i18n.get('notLoggedInTitle'),
          text: i18n.get('notLoggedInMsg'),
          type: 'error',
          showCancelButton: true,
        }).then((result) => {
          if (result.value === true) window.open('https://store.steampowered.com/');
        });
      }
    }
  },
  sync(a = []) {
    if (!isArray(a) || a.length === 0) {
      a.push({
        key: 'library',
        sync: true,
        save: true
      }, {
        key: 'games',
        sync: true,
        save: true
      }, );
    }

    const self = this;

    a.forEach((o) => {
      if (o.key === 'library' && o.sync !== false) {
        if (o.notify === true) swal.showLoading();
        GM_xmlhttpRequest({
          method: 'GET',
          url: `https://store.steampowered.com/dynamicstore/userdata/t=${Math.random()}`,
          onload(res) {
            const data = JSON.parse(res.response);

            if (!isObject(self.library)) self.reset([o]);

            self.library.owned = {
              app: isArray(data.rgOwnedApps) ? data.rgOwnedApps : [],
              sub: isArray(data.rgOwnedPackages) ? data.rgOwnedPackages : [],
            };
            self.library.wished = {
              app: isArray(data.rgWishlist) ? data.rgWishlist : [],
              sub: [],
            };
            self.library.ignored = {
              app: isArray(data.rgIgnoredApps) ? data.rgIgnoredApps : [],
              sub: isArray(data.rgIgnoredPackages) ? data.rgIgnoredPackages : [],
            };
            self.library.lastSync = Date.now();
            self.save([o]);

            if (o.notify === true) {
              swal({
                title: i18n.get('syncSuccessTitle'),
                text: i18n.get('syncSuccess'),
                type: 'success',
                timer: 3000,
              });
            }

            if (typeof o.callback === 'function') o.callback();
          },
          onerror() {
            swal({
              title: i18n.get('syncFailTitle'),
              text: i18n.get('syncFail'),
              type: 'error',
              confirmButtonText: i18n.get('visitSteam'),
              showCancelButton: true,
            }).then((result) => {
              if (result.value === true) window.open('https://store.steampowered.com/');
            });
          },
        });
      }
      if (o.key === 'games' && o.sync !== false) {
        GM_xmlhttpRequest({
          method: 'GET',
          url: 'https://steamspy.com/api.php?request=all',
          onload(res) {
            try {
              const data = JSON.parse(res.response);

              self.games = {
                list: Object.keys(data).map(x => parseInt(x, 10)),
                lastSync: Date.now(),
              };
              self.save([o]);

              if (typeof o.callback === 'function') o.callback();
            } catch (e) {
              throw e.stack;
            }
          },
        });
      }
    });
  },
  reset(a = []) {
    if (!isArray(a) || a.length === 0) {
      a.push({
        key: 'library',
        reset: true,
        save: true
      }, {
        key: 'games',
        reset: true,
        save: true
      }, );
    }

    a.forEach((o) => {
      if (o.key === 'library' && o.reset !== false) {
        this.library = {
          lastSync: 0,
          owned: {
            app: [],
            sub: []
          },
          wished: {
            app: [],
            sub: []
          },
          ignored: {
            app: [],
            sub: []
          },
        };
      }
      if (o.key === 'games' && o.reset !== false) {
        this.games = {
          lastSync: 0,
          list: [],
        };
      }

      if (o.save !== false) this.save([o]);
    });
  },
  save(a = []) {
    if (!isArray(a) || a.length === 0) {
      a.push({
        key: 'library',
        save: true
      }, {
        key: 'games',
        save: true
      }, );
    }

    a.forEach((o) => {
      if (has.call(this, o.key) && o.save !== false) {
        GM_setValue(`SBSE_steam_${o.key}`, JSON.stringify(this[o.key]));
        if (typeof o.callback === 'function') o.callback();
      }
    });
  },
  lastSync(key) {
    return has.call(this, key) ? this[key].lastSync : null;
  },
  isOwned(o) {
    return this.library.owned.app.includes(o.app) || this.library.owned.sub.includes(o.sub);
  },
  isWished(o) {
    return this.library.wished.app.includes(o.app) || this.library.wished.sub.includes(o.sub);
  },
  isIgnored(o) {
    return this.library.ignored.app.includes(o.app) || this.library.ignored.sub.includes(o.sub);
  },
  isGame(o) {
    return this.games.list.length > 0 && this.games.list.includes(o.app);
  },
  isDLC(o) {
    return has.call(o, 'app') && this.games.list.length > 0 && !this.games.list.includes(o.app);
  },
  isPackage(o) {
    return has.call(o, 'sub');
  },
  init() {
    if (!isObject(this.library) ||
      !has.call(this.library, 'owned') ||
      !has.call(this.library, 'wished') ||
      !has.call(this.library, 'ignored')) this.reset([{
      key: 'library'
    }]);

    if (!isObject(this.games) ||
      !has.call(this.games, 'list')) this.reset([{
      key: 'games'
    }]);

    if (config.get('autoSyncLibrary')) {
      // sync Steam library every 10 min
      const libraryTimer = 10 * 60 * 1000;
      const libraryLastSync = this.lastSync('library');

      if (!libraryLastSync || libraryLastSync < (Date.now() - libraryTimer)) this.sync([{
        key: 'library'
      }]);

      // sync Steam games list every day
      const gamesTimer = 1 * 24 * 60 * 60 * 1000;
      const gamesLastSync = this.lastSync('games');

      if (!gamesLastSync || gamesLastSync < (Date.now() - gamesTimer) || this.games.list.length === 0) this.sync([{
        key: 'games'
      }]);
    }

    // delete odd values
    GM_deleteValue('SBSE_steam');
  },
};
const activator = {
  activated: JSON.parse(GM_getValue('SBSE_activated', '[]')),
  isActivated(key) {
    return this.activated.includes(key);
  },
  pushActivated(key) {
    this.activated.push(key);
    GM_setValue('SBSE_activated', JSON.stringify(this.activated));
  },
  keyDetails: {},
  isOwned(key) {
    return has.call(this.keyDetails, key) ? this.keyDetails[key].owned : false;
  },
  pushKeyDetails(data) {
    if (!has.call(this.keyDetails, data.key)) this.keyDetails[data.key] = data;
  },
  getKeyDetails(key) {
    return has.call(this.keyDetails, key) ? this.keyDetails[key] : null;
  },
  results: [],
  resultDetails(result) {
    // result from Steam
    if (result.SBSE !== true) {
      // get status
      let status = i18n.get('failStatus');
      let statusMsg = i18n.get('failDetailUnexpected');
      const errorCode = result.purchase_result_details;
      const errors = {
        14: i18n.get('failDetailInvalidKey'),
        15: i18n.get('failDetailUsedKey'),
        53: i18n.get('failDetailRateLimited'),
        13: i18n.get('failDetailCountryRestricted'),
        9: i18n.get('failDetailAlreadyOwned'),
        24: i18n.get('failDetailMissingBaseGame'),
        36: i18n.get('failDetailPS3Required'),
        50: i18n.get('failDetailGiftWallet'),
      };

      if (result.success === 1) {
        status = i18n.get('successStatus');
        statusMsg = i18n.get('successDetail');
      } else if (result.success === 2) {
        if (has.call(errors, errorCode)) statusMsg = errors[errorCode];
      }

      result.status = `${status}/${statusMsg}`;

      // get description
      const info = result.purchase_receipt_info;
      const chuncks = [];

      if (info && info.line_items) {
        info.line_items.forEach((item) => {
          const chunk = [];

          if (item.packageid > 0) chunk.push(`sub: ${item.packageid}`);
          if (item.appid > 0) chunk.push(`app: ${item.appid}`);
          chunk.push(item.line_item_description);

          chuncks.push(chunk.join(' '));
        });
      }

      result.descripton = chuncks.join(', ');
    }

    const temp = [result.key];

    if (result.status) temp.push(result.status);
    if (result.descripton) temp.push(result.descripton);

    return temp.join(' | ');
  },
  activate(keys, callback) {
    this.results.length = 0;

    const updateResults = () => {
      $('.SBSE-container__content__model[data-feature="SBSE"] > textarea').val(this.results.concat(keys).join(eol));
    };
    const activateHandler = () => {
      const key = keys.shift();

      if (key) {
        if (this.isActivated(key)) {
          this.results.push(this.resultDetails({
            SBSE: true,
            key,
            status: `${i18n.get('skippedStatus')}/${i18n.get('activatedDetail')}`,
            descripton: i18n.get('noItemDetails'),
          }));
          updateResults();

          // next key
          activateHandler();
        } else if (this.isOwned(key) && !config.get('activateAllKeys')) {
          const detail = this.getKeyDetails(key);
          const description = [];

          ['app', 'sub'].forEach((type) => {
            if (has.call(detail, type)) description.push(`${type}: ${detail[type]} ${detail.title}`);
          });

          this.results.push(this.resultDetails({
            SBSE: true,
            key,
            status: `${i18n.get('skippedStatus')}/${i18n.get('failDetailAlreadyOwned')}`,
            descripton: description.join(),
          }));
          updateResults();

          // next key
          activateHandler();
        } else {
          const self = this;

          GM_xmlhttpRequest({
            method: 'POST',
            url: 'https://store.steampowered.com/account/ajaxregisterkey/',
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
              Origin: 'https://store.steampowered.com',
              Referer: 'https://store.steampowered.com/account/registerkey',
            },
            data: `product_key=${key}&sessionid=${config.get('sessionID')}`,
            onload(res) {
              if (res.status === 200) {
                const result = JSON.parse(res.response);

                // update activated
                const failCode = result.purchase_result_details;
                if (result.success === 1 || [14, 15, 9].includes(failCode)) {
                  self.pushActivated(key);

                  // dispatch activated event
                  $(document).trigger('activated', [key, result]);
                }

                result.key = key;
                self.results.push(self.resultDetails(result));
                updateResults();

                // next key
                setTimeout(activateHandler.bind(self), 2000);
              } else {
                const errorMsg = [];

                errorMsg.push('<pre class="SBSE-errorMsg">');
                errorMsg.push(`sessionID: ${config.get('sessionID') + eol}`);
                errorMsg.push(`autoUpdate: ${config.get('autoUpdateSessionID') + eol}`);
                errorMsg.push(`status: ${res.status + eol}`);
                errorMsg.push(`response: ${res.response + eol}`);
                errorMsg.push('</pre>');

                swal({
                  title: i18n.get('failTitle'),
                  html: i18n.get('failDetailRequestFailedNeedUpdate') + eol + errorMsg.join(''),
                  type: 'error',
                });
                steam.getSessionID();
                if (typeof callback === 'function') callback();
              }
            },
          });
        }
      } else if (typeof callback === 'function') callback();
    };

    activateHandler();
  },
};

// models
const settings = {
  model: null,
  getModel() {
    return this.model instanceof $ ? this.model : $();
  },
  display() {
    swal({
      title: i18n.get('settingsTitle'),
      onBeforeOpen: (dom) => {
        $(dom).find('.swal2-content').append(settings.getModel());
      },
    });
  },
  init() {
    const settingDetails = [{
      name: i18n.get('settingsAutoUpdateSessionID'),
      configItem: 'autoUpdateSessionID',
      type: 'switch',
    }, {
      name: i18n.get('settingsSessionID'),
      configItem: 'sessionID',
      type: 'text',
    }, {
      name: i18n.get('settingsAutoSyncLibrary'),
      configItem: 'autoSyncLibrary',
      type: 'switch',
    }, {
      name: i18n.get('settingsSyncLibrary'),
      configItem: 'syncLibrary',
      type: 'button',
      textContent: i18n.get('settingsSyncLibraryButton'),
    }, {
      name: i18n.get('settingsLanguage'),
      configItem: 'language',
      type: 'select',
    }, {
      name: i18n.get('settingsASFFormat'),
      configItem: 'ASFFormat',
      type: 'switch',
    }, {
      name: i18n.get('settingsTitleComesLast'),
      configItem: 'titleComesLast',
      type: 'switch',
    }, {
      name: i18n.get('settingsActivateAllKeys'),
      configItem: 'activateAllKeys',
      type: 'switch',
    }, {
      name: i18n.get('settingsEnableTooltips'),
      configItem: 'enableTooltips',
      type: 'switch',
    }, {
      name: i18n.get('settingshighlightedRegions'),
      configItem: 'highlightedRegions',
      type: 'button',
      textContent: i18n.get('settingshighlightedRegionsButton'),
    }, {
      name: i18n.get('settingsEnableASFIPC'),
      configItem: 'enableASFIPC',
      type: 'switch',
    }, {
      name: i18n.get('settingsASFWSProtocol'),
      configItem: 'ASFWSProtocol',
      type: 'select',
      options: ['ws', 'wss'],
    }, {
      name: i18n.get('settingsASFIPCProtocol'),
      configItem: 'ASFIPCProtocol',
      type: 'select',
      options: ['http', 'https'],
    }, {
      name: i18n.get('settingsASFIPCServer'),
      configItem: 'ASFIPCServer',
      type: 'text',
    }, {
      name: i18n.get('settingsASFIPCPort'),
      configItem: 'ASFIPCPort',
      type: 'text',
    }, {
      name: i18n.get('settingsASFIPCPassword'),
      configItem: 'ASFIPCPassword',
      type: 'text',
    }];
    const $model = $('<div class="SBSE-container__content__model" data-feature="setting"><table></table></div>');

    // append settings
    settingDetails.forEach((detail) => {
      const $tr = $(`<tr><td class="name">${detail.name}</td><td class="value"></td></tr>`).appendTo($model.find('table'));

      switch (detail.type) {
        case 'switch':
          $tr.find('.value').append(`
            <label class="SBSE-switch">
              <input type="checkbox" data-config="${detail.configItem}">
              <span class="SBSE-switch__slider"></span>
            </label>
          `);
          break;
        case 'text':
          $tr.find('.value').append(`<input type="text" data-config="${detail.configItem}" value="${config.get(detail.configItem)}">`);
          break;
        case 'button':
          $tr.find('.value').append(`<button data-config="${detail.configItem}">${detail.textContent}</button>`);
          break;
        case 'select':
          $tr.find('.value').append(`<select data-config="${detail.configItem}"></select>`);
          if (detail.options) $tr.find('select').append(`${detail.options.map(x => `<option value="${x}">${x}</option>`)}`);
          break;
        default:
      }
    });

    // append report section
    $model.find('table').append(`
      <tr>
        <td class="name">${i18n.get('settingsReportIssues')}</td>
        <td class="value">
          <a href="https://keylol.com/t305330-1-1" target="_blank">其乐 Keylol</a>
          <a href="https://github.com/clancy-chao/Steam-Bundle-Sites-Extension/issues" target="_blank">GitHub</a>
        </td>
      </tr>
    `);

    // apply settings
    const $sessionID = $model.find('[data-config="sessionID"]');
    const $language = $model.find('[data-config="language"]');
    const $ASFIPC = $model.find('[data-config^="ASFIPC"], [data-config^="ASFWS"]');

    // toggles
    $model.find('.SBSE-switch input[type="checkbox"]').each((i, input) => {
      const $input = $(input);

      $input.prop('checked', config.get(input.dataset.config));
      $input.on('change', (e) => {
        swal.showLoading();

        const configItem = e.delegateTarget.dataset.config;
        const state = e.delegateTarget.checked;

        config.set(configItem, state);

        if (configItem === 'autoUpdateSessionID') $sessionID.prop('disabled', state);
        if (configItem === 'enableASFIPC') $ASFIPC.prop('disabled', !state);

        setTimeout(swal.hideLoading, 500);
      });
    });

    // toggle - disable related fields
    // sessionID
    $sessionID.prop('disabled', config.get('autoUpdateSessionID'));
    $ASFIPC.prop('disabled', !config.get('enableASFIPC'));

    // input text
    $model.find('input[type="text"]').on('input', (e) => {
      swal.showLoading();

      const configItem = e.delegateTarget.dataset.config;
      const value = e.delegateTarget.value.trim();

      config.set(configItem, value);

      setTimeout(swal.hideLoading, 500);
    });

    // select
    $model.find('select').on('change', (e) => {
      swal.showLoading();

      const configItem = e.delegateTarget.dataset.config;
      const value = e.delegateTarget.value;

      config.set(configItem, value);
      if (configItem === 'language') i18n.set();

      setTimeout(swal.hideLoading, 500);
    });

    // select - language options
    Object.keys(i18n.data).forEach((lang) => {
      $language.append(new Option(i18n.data[lang].name, lang));
    });

    // select - language
    $language.val(config.get('language'));
    // select - ASF protocols
    $ASFIPC.filter('select[data-config="ASFIPCProtocol"]').val(config.get('ASFIPCProtocol'));
    $ASFIPC.filter('select[data-config="ASFWSProtocol"]').val(config.get('ASFWSProtocol'));

    // button - sync library
    $model.find('[data-config="syncLibrary"]').on('click', () => {
      steam.sync([{
        key: 'library',
        notify: true
      }]);
    });

    // button - select regions
    $model.find('[data-config="highlightedRegions"]').on('click', () => {
      swal({
        title: i18n.get('settingshighlightedRegionsButton'),
        width: '1200px',
        onBeforeOpen: (dom) => {
          const data = Object.assign({}, ISO2.name.english);;
          const sortedCode = Object.keys(data).sort((a, b) => data[a] < data[b] ? -1 : data[a] > data[b] ? 1 : 0);
          const separators = {
            A: 'A',
            B: 'B',
            C: 'C',
            D: 'D, E, F',
            G: 'G, H, I',
            J: 'J, K, L',
            M: 'M',
            N: 'N',
            O: 'O, P, Q, R',
            S: 'S',
            T: 'T',
            U: 'U, V, W, X, Y, Z',
          };
          let html = '';

          sortedCode.forEach((code) => {
            if (separators[data[code].charAt(0)]) {
              html += `<span class="separator">${separators[data[code].charAt(0)]}</span>`;
              separators[data[code].charAt(0)] = undefined;
            }
            html += `<span data-code="${code}">${ISO2.get(code)}</span>`;
          });

          $(dom).find('.swal2-content').append(`<div class="SBSE-grid">${html}</div>`);
          $(dom).find('.swal2-content span[data-code]').on('click', (e) => {
            $(e.delegateTarget).toggleClass('selected');
          });

          config.get('highlightedRegions').forEach((code) => {
            $(dom).find(`.swal2-content span[data-code="${code}"]`).addClass('selected');
          });
        },
        onClose: (dom) => {
          config.set('highlightedRegions', $(dom).find(`.swal2-content span[data-code].selected`).map((i, ele) => $(ele).attr('data-code')).get());
        },
        onAfterClose: settings.display,
      });
    });

    this.model = $model;
  },
};
const SBSE = {
  model: null,
  handlers: {
    extract() {
      return {
        items: []
      };
    },
    retrieve() {
      const $model = SBSE.getModel();
      const data = this.extract();
      const keys = [];
      const includeTitle = $model.find('.SBSE-checkbox-title').prop('checked');
      const joinKeys = $model.find('.SBSE-checkbox-join').prop('checked');
      const selected = $model.find('.SBSE-select-filter').val() || 'All';
      const skipUsed = $model.find('.SBSE-checkbox-skipUsed').prop('checked');
      const skipMarketListing = !$model.find('.SBSE-checkbox-marketListings').prop('checked');
      const separator = joinKeys ? ',' : eol;
      const prefix = joinKeys && config.get('ASFFormat') ? '!redeem ' : '';

      for (let i = 0; i < data.items.length; i += 1) {
        const item = data.items[i];
        let skip = false;

        if (selected === 'Owned' && !item.owned) skip = true;
        if (selected === 'NotOwned' && item.owned) skip = true;
        if (skipUsed && item.used) skip = true;
        if (skipMarketListing && item.marketListing) skip = true;

        if (!skip) {
          const temp = [item.key];

          if (config.get('ASFFormat')) {
            if (!joinKeys) temp.unshift(item.title);

            keys.push(temp.join(tab));
          } else {
            if (includeTitle) temp.unshift(item.title);
            if (config.get('titleComesLast')) temp.reverse();

            keys.push(temp.join(', '));
          }
        }
      }

      $model.find('textarea').val(prefix + keys.join(separator));
    },
    activate(e) {
      const $textarea = SBSE.getModel().find('textarea');
      const keys = unique($textarea.val().match(regKey));

      if (keys.length > 0) {
        const $activateBtn = $(e.currentTarget);

        $activateBtn.prop('disabled', true).addClass('SBSE-button--working');
        $textarea.prop('disabled', true);

        $textarea.val(keys.join(eol));
        activator.activate(keys, () => {
          $activateBtn.prop('disabled', false).removeClass('SBSE-button--working');
          $textarea.prop('disabled', false);
        });
      } else $textarea.val(i18n.get('emptyInput'));
    },
    copy() {
      SBSE.getModel().find('textarea').select();
      document.execCommand('copy');
    },
    reset() {
      SBSE.getModel().find('textarea').val('');
    },
    export (e) {
      const data = this.extract();

      if (data.items.length > 0) {
        const exportBtn = e.target;

        exportBtn.removeAttribute('href');
        exportBtn.removeAttribute('download');

        const fileType = exportBtn.dataset.filetype || 'txt';
        const filename = data.filename.replace(/[\\/:*?"<>|!]/g, '');
        const separator = {
          txt: ', ',
          csv: ',',
          keys: tab,
        };
        const formattedData = data.items.map((line) => {
          const temp = [];

          if (line.title) temp.push(line.title.replace(/,/g, ' '));
          temp.push(line.key);

          return temp.join(separator[fileType]);
        }).join(eol);

        exportBtn.href = `data:text/${fileType};charset=utf-8,\ufeff${encodeURIComponent(formattedData)}`;
        exportBtn.download = `${filename}.${fileType}`;
      }
    },
  },
  setHandlers(handlers) {
    this.handlers = Object.assign(this.handlers, handlers);
  },
  getModel() {
    return this.model instanceof $ ? this.model : $();
  },
  init() {
    // construct SBSE model
    const $model = $('<div class="SBSE-container__content__model" data-feature="SBSE"></div>');

    $model.append(`
      <textarea></textarea>
        <div>
          <button class="SBSE-button SBSE-button-reveal">${i18n.get('buttonReveal')}</button>
          <button class="SBSE-button SBSE-button-retrieve">${i18n.get('buttonRetrieve')}</button>
          <button class="SBSE-button SBSE-button-activate">${i18n.get('buttonActivate')}</button>
          <button class="SBSE-button SBSE-button-copy">${i18n.get('buttonCopy')}</button>
          <button class="SBSE-button SBSE-button-reset">${i18n.get('buttonReset')}</button>
          <div class="SBSE-dropdown SBSE-dropdown-export">
            <button class="SBSE-button SBSE-button-export">${i18n.get('buttonExport')}</button>
            <ul class="SBSE-dropdown__list SBSE-dropdown__list-export">
              <li><a data-fileType="txt">.txt</a></li>
              <li><a data-fileType="csv">.csv</a></li>
              <li><a data-fileType="keys">.keys</a></li>
            </ul>
          </div>
          <label><input type="checkbox" class="SBSE-checkbox SBSE-checkbox-title" data-config="SBSE_ChkTitle">${i18n.get('checkboxIncludeGameTitle')}</label>
          <label><input type="checkbox" class="SBSE-checkbox SBSE-checkbox-join" data-config="SBSE_ChkJoin">${i18n.get('checkboxJoinKeys')}</label>
          <select class="SBSE-select SBSE-select-filter">
            <option value="All" selected>${i18n.get('selectFilterAll')}</option>
            <option value="Owned">${i18n.get('selectFilterOwned')}</option>
            <option value="NotOwned">${i18n.get('selectFilterNotOwned')}</option>
          </select>
          <button class="SBSE-button-setting"> </button>
      </div>
    `);

    // bind handlers
    const handlers = this.handlers;

    $model.find('button').click((e) => {
      e.preventDefault();
    });
    $model.find('.SBSE-button-reveal').on('click.reveal', (e) => {
      handlers.reveal(e);
    });
    $model.find('.SBSE-button-retrieve').on('click.retrieve', (e) => {
      handlers.retrieve(e);
    });
    $model.find('.SBSE-button-activate').on('click.activate', (e) => {
      handlers.activate(e);
    });
    $model.find('.SBSE-button-copy').on('click.copy', (e) => {
      handlers.copy(e);
    });
    $model.find('.SBSE-button-reset').on('click.reset', (e) => {
      handlers.reset(e);
    });
    $model.find('.SBSE-dropdown__list-export').on('click.export', (e) => {
      handlers.export(e);
    });
    $model.find('.SBSE-button-setting').on('click.setting', settings.display);
    $model.find('.SBSE-checkbox').on('change', (e) => {
      const key = e.currentTarget.dataset.config;

      if (key.length > 0) config.set(key, e.currentTarget.checked);
    });

    // apply settings
    if (config.get('SBSE_ChkTitle')) $model.find('.SBSE-checkbox-title').prop('checked', true);
    if (config.get('SBSE_ChkJoin')) $model.find('.SBSE-checkbox-join').prop('checked', true);

    this.model = $model;
  },
};
const ASF = {
  model: null,
  terminal: {},
  getModel() {
    return this.model instanceof $ ? this.model : $();
  },
  scrollToBottom(key) {
    const terminal = this.terminal[key];

    if (terminal instanceof $) terminal.scrollTop(terminal[0].scrollHeight);
  },
  push(key, line) {
    this.terminal[key].append(`<span class="SBSE-terminal__message">${line}</span>`);
    this.scrollToBottom(key);
  },
  listenLogs() {
    const self = this;

    self.push('log', 'Establishing connection to ASF IPC server');

    const protocol = config.get('ASFWSProtocol');
    const domain = `${config.get('ASFIPCServer')}:${config.get('ASFIPCPort')}`;
    const password = config.get('ASFIPCPassword');
    const url = `${protocol}://${domain}/Api/NLog${password.length > 0 ? `?password=${password}` : ''}`;

    try {
      const ws = new WebSocket(url);

      ws.addEventListener('open', () => {
        self.push('log', 'Connection established');
      });
      ws.addEventListener('error', () => {
        self.push('log', 'An error occured while connecting to ASF IPC server');
      });
      ws.addEventListener('message', (e) => {
        try {
          const data = JSON.parse(e.data);

          self.push('log', data.Result);
        } catch (error) {
          self.push('log', error.stack);
        }
      });
    } catch (error) {
      self.push('log', `Failed to establish connection, error message: ${error.message}`);
    }
  },
  initCommands: async () => {
    const ipc = {
      protocol: config.get('ASFIPCProtocol'),
      server: config.get('ASFIPCServer'),
      port: config.get('ASFIPCPort'),
      password: config.get('ASFIPCPassword'),
      commands: {},
      bots: [],
    };
    const self = ASF;
    const requestOptions = (method, pathname) => {
      const options = {
        method
      };

      options.url = `${ipc.protocol}://${ipc.server}:${ipc.port + pathname}`;
      if (ipc.password.length > 0) options.headers = {
        Authentication: ipc.password
      };

      return options;
    };
    const sendCommand = async (command) => {
      self.push('commands', command);

      const res = await request(requestOptions('POST', `/Api/Command/${encodeURIComponent(command)}`));

      try {
        const data = JSON.parse(res.response);
        const msg = data.Success === true ? data.Result : data.Message;

        self.push('commands', msg);
      } catch (error) {
        self.push('commands', error.stack);
      }
    };

    // append terminal input
    const $input = $(`
      <span class="SBSE-terminal__input">
        <input type="text">
        <input type="text">
      </span>
    `).appendTo(self.terminal.commands).find('input:first-child');
    const $hint = $input.next('input');

    // bind event
    // display hint on input
    $input.on('input', () => {
      let newHint = '';
      let saved = $hint.attr('data-saved');
      const typed = $input.val().replace(/\s+/g, ' ');

      if (typed.length > 0) {
        const typedPieces = typed.split(' ');

        // perform a new search for command
        if (!saved || saved.length === 0 || saved.indexOf(typedPieces[0]) === -1) {
          saved = Object.keys(ipc.commands).find(x => x.indexOf(typedPieces[0]) === 0) || '';
        }

        const command = ipc.commands[saved];

        // found matching command
        if (isArray(command) && command.length > 0) {
          const hintPieces = command.slice(0);

          // skip 1st piece as no need to process the command
          for (let i = 1; i < typedPieces.length; i += 1) {
            if (typedPieces[i].length > 0) {
              let newHintPiece = command[i];

              // replace command argument if typed something
              if (typedPieces[i].length > 0) newHintPiece = typedPieces[i];

              // match bot name
              if (command[i] === '<Bots>' || command[i] === '<TargetBot>') {
                const found = ipc.bots.find(x => x.indexOf(typedPieces[i]) === 0);

                if (found) newHintPiece = found;
              }

              // multiple arguments for last typed piece
              if (i === typedPieces.length - 1 &&
                newHintPiece.includes(',') &&
                (command[i] === '<Bots>' ||
                  command[i] === '<GameIDs>' ||
                  command[i] === '<SteamIDs64>' ||
                  command[i] === '<AppIDs>' ||
                  command[i] === '<RealAppIDs>' ||
                  command[i] === '<AppIDsOrGameNames>' ||
                  command[i] === '<AppIDs,GameName>' ||
                  command[i] === '<Keys>' ||
                  command[i] === '<Modes>')) {
                if (newHintPiece.slice(-1) === ',') {
                  newHintPiece += command[i];
                } else if (command[i] === '<Bots>') {
                  const pieces = newHintPiece.split(',');
                  const last = pieces.length - 1;
                  const found = ipc.bots.find(x => x.indexOf(pieces[last]) === 0);

                  if (found) {
                    pieces[last] = found;
                    newHintPiece = pieces.join(',');
                  }
                }
              }

              hintPieces[i] = newHintPiece;
            }
          }

          newHint = hintPieces.filter(x => x.length > 0).join(' ');
        }
      } else saved = '';

      $hint.attr('data-saved', saved);
      $hint.val(newHint);
      $input.val(typed);
    });

    // detect key board event
    $input.on('keydown', (e) => {
      // right arrow key, auto complete hint
      if (e.keyCode === 39 && $hint.val().length > $input.val().length) {
        const bracket = $hint.val().indexOf('<');
        const text = bracket > -1 ? $hint.val().slice(0, bracket) : $hint.val();

        $input.val(text);
      }

      // enter key, send command
      if (e.keyCode === 13) {
        sendCommand($input.val());
        $input.val('');
        $hint.val('');
      }
    });

    // prevent the hint input getting focus
    $hint.on('focus', () => {
      $input.focus();
    });

    // focus input field when click empty space
    self.terminal.commands.parent().on('click', (e) => {
      if ($(e.target).is('.SBSE-terminal-commands')) $input.focus();
    });

    self.push('commands', 'Fetching commands from ASF wiki');

    // fetch commands
    const resCommands = await request({
      method: 'GET',
      url: 'https://github.com/JustArchiNET/ArchiSteamFarm/wiki/Commands',
    });

    if (resCommands.status === 200) {
      const html = resCommands.response.slice(resCommands.response.indexOf('<div id="wiki-body"'), resCommands.response.indexOf('<div id="wiki-rightbar"'));
      const $html = $(html);
      const commands = $html.find('h2:has(#user-content-commands-1) + table tbody tr td:first-child code').get().map(ele => ele.innerText.trim());

      commands.forEach((command) => {
        const pieces = command.split(' ');

        ipc.commands[pieces[0]] = pieces;
      });

      self.push('commands', 'Commands successfully fetched');
    } else self.push('commands', 'Failed to fetch commands from ASF wiki, please refrsh to try again');

    // fetch bots
    const resBots = await request(requestOptions('GET', '/api/bot/ASF'));

    if (resBots.status === 200) {
      try {
        const data = JSON.parse(resBots.response);

        ipc.bots = Object.keys(data.Result);
      } catch (e) {
        throw e;
      }
    }
  },
  init() {
    // construct SBSE model
    const $model = $('<div class="SBSE-container__content__model" data-feature="ASF"></div>');

    $model.append(`
      <div class="SBSE-terminal SBSE-terminal-commands"><div></div></div>
      <div class="SBSE-terminal SBSE-terminal-log SBSE-terminal--show"><div></div></div>
      <div>
        <button class="SBSE-button SBSE-button-commands">${i18n.get('buttonCommands')}</button>
        <button class="SBSE-button SBSE-button-log">${i18n.get('buttonLog')}</button>
        <button class="SBSE-button-setting"> </button>
      </div>
    `);

    $model.find('.SBSE-button-commands').on('click.commands', () => {
      $model.find('.SBSE-terminal--show').removeClass('SBSE-terminal--show');
      $model.find('.SBSE-terminal-commands').addClass('SBSE-terminal--show');
      $model.find('.SBSE-terminal__input input:first-child').focus();
    });
    $model.find('.SBSE-button-log').on('click.log', () => {
      $model.find('.SBSE-terminal--show').removeClass('SBSE-terminal--show');
      $model.find('.SBSE-terminal-log').addClass('SBSE-terminal--show');
    });
    $model.find('.SBSE-button-setting').on('click.setting', settings.display);

    this.model = $model;
    this.terminal.log = $model.find('.SBSE-terminal-log > div');
    this.terminal.commands = $model.find('.SBSE-terminal-commands > div');

    if (config.get('enableASFIPC')) {
      this.listenLogs();
      this.initCommands();
    } else {
      this.push('commands', 'ASF IPC feature not enabled, please go to settings and enable this feature');
      this.push('log', 'ASF IPC feature not enabled, please go to settings and enable this feature');
    }
  },
};
const container = {
  self: null,
  models: {},
  get(feature, handlers) {
    this.show(feature);
    if (isObject(handlers)) {
      if (feature === 'SBSE') SBSE.setHandlers(handlers);
      if (feature === 'ASF') ASF.setHandlers(handlers);
    }

    return this.self;
  },
  show(feature) {
    // nav
    this.self.find('.SBSE-container__nav__item--show').removeClass('SBSE-container__nav__item--show');
    this.self.find(`.SBSE-container__nav__item[data-feature="${feature}"]`).addClass('SBSE-container__nav__item--show');

    // content
    this.self.find('.SBSE-container__content__model--show').removeClass('SBSE-container__content__model--show');
    this.self.find(`.SBSE-container__content__model[data-feature="${feature}"]`).addClass('SBSE-container__content__model--show');
  },
  init() {
    this.self = $('<div class="SBSE-container"></div>');

    const $nav = $('<div class="SBSE-container__nav"></div>').appendTo(this.self);
    const $content = $('<div class="SBSE-container__content"></div>').appendTo(this.self);

    // construct nav
    $nav.append(`
      <ul>
        <li class="SBSE-container__nav__item" data-feature="SBSE"><span>Steam Ext</span></li>
        <li class="SBSE-container__nav__item" data-feature="ASF"><span>ASF IPC</span></li>
      </ul>
    `);

    // bind event
    $nav.find('.SBSE-container__nav__item').on('click', (e) => {
      const $target = $(e.delegateTarget);

      if (!$target.hasClass('SBSE-container__nav__item--show')) {
        container.show($target.attr('data-feature'));
      }
    });

    // append models to content block
    this.models.SBSE = SBSE.getModel();
    this.models.ASF = ASF.getModel();

    $content.append(Object.values(this.models));
  },
};

const keylolTooltip = {
  timeoutID: 0,
  load(data) {
    if (config.get('enableTooltips')) {
      const $container = $('<div/>');

      (Array.isArray(data) ? data : [data]).forEach((d) => {
        let type = null;

        if (has.call(d, 'sub')) type = 'sub';
        if (has.call(d, 'app')) type = 'app';
        if (type !== null) {
          const url = `https://steamdb.keylol.com/tooltip?v=4#${type}/${d[type]}#steam_info_${type}_${d[type]}_1`;

          $container.append(
            $(`<iframe id="SBSE-tooltip_${type + d[type]}" class="SBSE-tooltip" data-url="${url}"></iframe>`)
            .mouseenter(() => {
              clearTimeout(this.timeoutID);
            })
            .mouseout(this.hide),
          );
        }
      });

      $('body').append($container);
    }
  },
  show(e) {
    const $target = $(e.currentTarget);
    const json = $target.closest('.SBSE-item--processed').attr('data-gameinfo');

    if (json.length > 0 && config.get('enableTooltips')) {
      const data = JSON.parse(json);
      const opened = !!$('.SBSE-tooltip--show').length;

      ['app', 'sub'].forEach((type) => {
        const $tooltip = $(`#SBSE-tooltip_${type + data[type]}`);

        if ($tooltip.length > 0 && !opened) {
          // load tooltip
          if (!$tooltip.attr('src')) $tooltip.attr('src', $tooltip.attr('data-url'));

          $tooltip.css({
            top: e.clientY,
            left: e.clientX + 10,
          }).addClass('SBSE-tooltip--show');
          this.reposition($tooltip, $tooltip.height());
          $tooltip[0].contentWindow.postMessage('show', '*'); // get height

          $target.one('mouseout', () => {
            this.timeoutID = setTimeout(this.hide.bind(keylolTooltip), 500);
          });
        }
      });
    }
  },
  hide() {
    const $tooltip = $('.SBSE-tooltip--show');

    if ($tooltip.length > 0) {
      $tooltip.removeClass('SBSE-tooltip--show');
      $tooltip[0].contentWindow.postMessage('hide', '*');
    }
  },
  reposition($tooltip, height) {
    const $window = $(window);
    const $document = $(document);
    const offsetTop = $tooltip.offset().top - $document.scrollTop();
    const offsetLeft = $tooltip.offset().left - $document.scrollLeft();
    const overflowX = (offsetLeft + $tooltip.width()) - ($window.width() - 20);
    const overflowY = (offsetTop + height) - ($window.height() - 20);

    if (overflowY > 0) $tooltip.css('top', offsetTop - overflowY);
    if (overflowX > 0) $tooltip.css('left', offsetLeft - overflowX);
  },
  listen() {
    window.addEventListener('message', (e) => {
      if (e.origin === 'https://steamdb.keylol.com' && e.data.height && e.data.src) {
        const $tooltip = $(`.SBSE-tooltip[src="${e.data.src}"]`);

        $tooltip.height(e.data.height);
        this.reposition($tooltip, e.data.height);
      }
    });
  },
};
const siteHandlers = {
  indiegala() {
    // inject css
    GM_addStyle(`
      .SBSE-container { margin-top: 10px; }
      .SBSE-container__nav__item--show { border-bottom: 1px solid #CC001D; color: #CC001D; }
      .SBSE-container__content__model > textarea { border: 1px solid #CC001D; border-radius: 3px; }
      .SBSE-button { width: 100px; background-color: #CC001D; color: white; border: none; border-radius: 3px; }
      .swal2-popup .SBSE-switch__slider { margin: 0; }
      .SBSE-icon { margin-top: 15px; }
    `);

    const handlers = {
      extract() {
        const $tabCont = $('.profile-private-page-library-tab-cont');
        const $source = $tabCont.length > 1 ? $tabCont.filter('.profile-private-page-library-tab-active') : $tabCont;
        const bundleTitle = $('.profile-private-page-library-selected .profile-private-page-library-title').text().trim();
        const data = {
          title: bundleTitle,
          filename: `IndieGala ${bundleTitle} Keys`,
          items: [],
        };

        $source.find('ul[class^="profile-private-page"][class$="-active"]').find('.profile-private-page-library-subitem').each((i, ele) => {
          const $ele = $(ele);
          const key = $ele.find('input[class*="key-serial"]').val();

          if (key) {
            const d = JSON.parse($(ele).attr('data-gameinfo') || '{}');

            if (Object.keys(d).length === 0) {
              const $a = $ele.find('a[href*="steam"]');
              const matched = $a.attr('href').match(/steam.+\/(app|sub)\/(\d+)/);

              d.title = $ele.find('.profile-private-page-library-title *[title]').attr('title').trim();
              if (matched) d[matched[1]] = parseInt(matched[2], 10);
            }

            d.key = key;

            activator.pushKeyDetails(d);
            data.items.push(d);
          }
        });

        return data;
      },
      reveal(e) {
        const $tabCont = $('.profile-private-page-library-tab-cont');
        const $source = $tabCont.length > 1 ? $tabCont.filter('.profile-private-page-library-tab-active') : $tabCont;
        const $revealBtn = $(e.currentTarget);
        const selected = $('.SBSE-select-filter').val() || 'All';
        const handler = ($games, callback) => {
          const game = $games.shift();

          if (game) {
            const d = JSON.parse($(game).closest('.SBSE-item--processed').attr('data-gameinfo') || '{}');

            if (selected === 'All' || (selected === 'Owned' && d.owned) || (selected === 'NotOwned' && !d.owned)) {
              game.click();
              unsafeWindow.getSerialKeyGo = true; // fix: issue#27
              setTimeout(handler.bind(null, $games, callback), 700);
            } else setTimeout(handler.bind(null, $games, callback), 1);
          } else callback();
        };

        $revealBtn.addClass('SBSE-button--working');

        handler($source.find('button[onclick^="getSerialKey"]'), () => {
          $revealBtn.removeClass('SBSE-button--working');
          $('.SBSE-button-retrieve').click();
        });
      },
    };
    const process = ($nodes) => {
      const tooltipsData = [];
      const $source = $nodes && $nodes.length > 0 ? $nodes : $('.profile-private-page-library-subitem');

      $source.each((i, ele) => {
        const $ele = $(ele);
        const $a = $ele.find('a[href*="steam"]');
        const d = {
          title: $ele.find('.profile-private-page-library-title *[title]').attr('title').trim(),
        };

        if ($a.length > 0) {
          const matched = $a.attr('href').match(/steam.+\/(app|sub)\/(\d+)/);
          if (matched) d[matched[1]] = parseInt(matched[2], 10);

          // check if owned & wished
          d.owned = steam.isOwned(d);
          d.wished = steam.isWished(d);

          if (d.owned) $ele.addClass('SBSE-item--owned');
          if (d.wished) $ele.addClass('SBSE-item--wished');
        }

        // append icon
        $ele.find('.profile-private-page-library-title').after(
          $('<span class="SBSE-icon"></span>').mouseenter(keylolTooltip.show.bind(keylolTooltip)),
        );

        tooltipsData.push(d);

        $ele.attr('data-gameinfo', JSON.stringify(d)).addClass('SBSE-item--processed SBSE-item--steam');
      });

      // load Keylol tooltip
      keylolTooltip.load(tooltipsData);
    };
    const $container = container.get('SBSE', handlers);

    process();

    // insert container
    $('.profile-private-page-library-menu').eq(0).before($container);

    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        Array.from(mutation.addedNodes).forEach((addedNode) => {
          if (addedNode.nodeType === 1 && addedNode.classList.contains('profile-private-page-library-subitem')) process($(addedNode));
        });
      });
    });

    observer.observe($('.profile-private-page-library-cont')[0], {
      childList: true,
      subtree: true,
    });
  },
  fanatical() {
    // inject css
    GM_addStyle(`
      .SBSE-container { margin-top: 10px; }
      .SBSE-container__nav { background-color: rgb(28, 28, 28); }
      .SBSE-container__nav__item--show {
        border-bottom: 1px solid #ff9800;
        color: #ff9800;
      }
      .SBSE-container__content { margin: 0; }
      .SBSE-container__content__model > textarea { background-color: #434343; color: #eee; }
      .SBSE-container__content__model label { color: #DEDEDE; }
      .SBSE-button, .SBSE-select { border: 1px solid transparent; background-color: #1c1c1c; color: #eee; }
      .SBSE-button:hover, .SBSE-select:hover { color: #A8A8A8; }
      .SBSE-button--narrow { width: 80px; }

      /* currency converter */
      .SBSE-priceExt { positon: relative; }
      .SBSE-priceExt ~ .SBSE-priceExt { display: none; }
      .SBSE-priceExt--portrait { width: 100%; padding: 0 .875rem 0 .875rem; }
      .SBSE-priceExt--portrait > div { padding: 1rem; }
      .SBSE-priceExt--portrait .SBSE-priceExt__currencyToggler {
        width: 100%; height: 40px;
        margin-bottom: 10px;
        font-size: 20px;
        border-radius: 3px;
      }
      .SBSE-priceExt--landscape { padding: 1rem; }
      .SBSE-priceExt--landscape > div { display: flex; align-items: center; justify-content: space-evenly; }
      .SBSE-priceExt--landscape .SBSE-priceExt__currencyToggler {
        width: 300px; height: 40px;
        font-size: 20px;
        border-radius: 3px;
      }
      .SBSE-priceExt__pricingDetail { background-color: transparent; }
      .SBSE-priceExt__pricingDetail th { padding-top: 10px; }
      .SBSE-priceExt__pricingDetail .cheapest { border-bottom: 1px solid #ff9800; font-weight: bold; }
      .SBSE-priceExt__pricingDetail .currency-flag { vertical-align: text-bottom; }
      .swal2-popup table { background-color: white; }
      .SBSE-icon { vertical-align: bottom; }
    `);

    const fetchAPIData = async (s, c) => {
      let slug = s;
      let callback = c;
      if (typeof s === 'function') {
        callback = s;
        slug = location.href.split('/').pop();
      }

      let JSONString = GM_getValue(`Fanatical-${slug}`, '');

      if (JSONString.length === 0) {
        const res = await fetch(`https://www.fanatical.com/api/products/${slug}`);

        if (res.ok) {
          JSONString = await res.text();

          GM_setValue(`Fanatical-${slug}`, JSONString);
        } else JSONString = '{}';
      }

      if (typeof callback === 'function') callback(JSON.parse(JSONString));
    };
    const productHandler = async (APIData) => {
      if (Object.keys(APIData).length > 0) {
        const language = config.get('language');
        const $priceExt = $(`
          <div class="SBSE-priceExt SBSE-priceExt--portrait">
            <div>
              <select class="SBSE-priceExt__currencyToggler"></select>
            </div>
          </div>
        `);
        const $currencyToggler = $priceExt.find('.SBSE-priceExt__currencyToggler');
        const $pricingDetail = $('<table class="SBSE-priceExt__pricingDetail"></table>');
        const selectedCurrency = GM_getValue('SBSE_selectedCurrency', 'USD');
        const isStarDeal = !!$('.stardeal-purchase-info').length;
        let starDeal = {};

        if (isStarDeal) {
          // fetch star-deal data
          const res = await fetch('https://www.fanatical.com/api/star-deal');

          if (res.ok) starDeal = await res.json();
        }

        // change orientation
        if (isStarDeal || $('.background-bundle, .bundle-header.container-fluid').length > 0) {
          $priceExt.toggleClass('SBSE-priceExt--portrait SBSE-priceExt--landscape container');
        }

        Object.keys(xe.currencies).forEach((currency) => {
          const selected = currency === selectedCurrency ? ' selected' : '';

          $currencyToggler.append(
            $(`<option value="${currency}"${selected}>${xe.currencies[currency][language]}</option>`),
          );
        });

        $currencyToggler.change(() => {
          xe.update($currencyToggler.val());
        });

        // bundle page
        APIData.bundles.forEach((tier, index) => {
          const $detail = $pricingDetail.clone();

          if (APIData.bundles.length > 1) $detail.append(`<tr><th colspan="3">Tier ${index + 1}</th></tr>`);
          Object.keys(tier.price).sort().forEach((currency) => {
            const value = tier.price[currency];
            const symbol = xe.currencies[currency].symbol;
            const decimalPlace = xe.currencies[currency].decimal ? 2 : 0;

            $detail.append(`
              <tr class="tier${index + 1}">
                <td><div class="currency-flag currency-flag-${currency.toLowerCase()}"></div></td>
                <td>${symbol + (value / 100).toFixed(decimalPlace)}</td>
                <td> ≈ <span class="SBSE-price" data-currency="${currency}" data-value="${value}"></span></td>
              </tr>
            `);
          });

          $detail.appendTo($currencyToggler.parent());
        });

        // game page
        if (location.href.includes('/game/') || location.href.includes('/dlc/')) {
          let discount = 1;

          if (has.call(APIData, 'current_discount') &&
            new Date(APIData.current_discount.until).getTime() > Date.now()
          ) discount = 1 - APIData.current_discount.percent;

          if (isStarDeal) discount = 1 - ($('.discount-percent').text().replace(/\D/g, '') / 100);

          Object.keys(APIData.price).sort().forEach((currency) => {
            let value = Math.trunc(APIData.price[currency] * discount);
            const symbol = xe.currencies[currency].symbol;
            const decimalPlace = xe.currencies[currency].decimal ? 2 : 0;

            // if star-deal data loaded successfully
            if (has.call(starDeal, 'promoPrice')) value = starDeal.promoPrice[currency];

            $pricingDetail.append(`
              <tr class="tier1">
                <td><div class="currency-flag currency-flag-${currency.toLowerCase()}"></div></td>
                <td>${symbol + (value / 100).toFixed(decimalPlace)}</td>
                <td> ≈ <span class="SBSE-price" data-currency="${currency}" data-value="${value}"></span></td>
              </tr>
            `).appendTo($currencyToggler.parent());
          });
        }

        $('.product-commerce-container').append($priceExt);
        $('.stardeal-purchase-info, .bundle-header').filter(':visible').eq(0).after($priceExt);
        xe.update(selectedCurrency);

        // highlight the cheapest
        for (let i = 1; i < 10; i += 1) {
          const $prices = $(`.tier${i} .SBSE-price`);

          if ($prices.length === 0) break;

          $($prices.toArray().sort((a, b) => a.textContent.replace(/\D/g, '') - b.textContent.replace(/\D/g, '')).shift()).closest('tr').addClass('cheapest');
        }
      }
    };
    const handlers = {
      extract() {
        const bundleTitle = $('h5').eq(0).text().trim();
        const data = {
          title: bundleTitle,
          filename: `Fanatical ${bundleTitle} Keys`,
          items: [],
        };

        $('.account-content .order-item-details-container').each((i, orderItem) => {
          const $orderItem = $(orderItem);
          const key = $orderItem.find('input[type="text"]').val();

          if (key) {
            const d = JSON.parse($orderItem.closest('.SBSE-item--processed').attr('data-gameinfo') || '{}');

            if (Object.keys(d).length === 0) {
              d.title = $orderItem.find('.game-name').text().trim();
            }

            d.key = key;

            activator.pushKeyDetails(d);
            data.items.push(d);
          }
        });

        return data;
      },
      reveal(e) {
        const $revealBtn = $(e.currentTarget);
        const selected = $('.SBSE-select-filter').val() || 'All';
        const handler = ($games, callback) => {
          const game = $games.shift();

          if (game) {
            const d = JSON.parse($(game).closest('.SBSE-item--processed').attr('data-gameinfo') || '{}');

            if (selected === 'All' || (selected === 'Owned' && d.owned) || (selected === 'NotOwned' && !d.owned)) {
              game.click();
              setTimeout(handler.bind(null, $games, callback), 300);
            } else setTimeout(handler.bind(null, $games, callback), 1);
          } else setTimeout(callback, 500);
        };

        $revealBtn.addClass('SBSE-button--working');

        handler($('.account-content .key-container button'), () => {
          $revealBtn.removeClass('SBSE-button--working');
          $('.SBSE-button-retrieve').click();
        });
      },
    };
    const process = ($node) => {
      // empty textarea
      SBSE.getModel().find('textarea').val('');

      // retrieve title
      $('.account-content h5').each((i, h5) => {
        const title = h5.textContent.trim();
        const slug = title.toLowerCase().replace(/ /g, '-').replace(/[^a-z0-9-]/g, '');

        fetchAPIData(slug, (APIData) => {
          if (Object.keys(APIData).length > 0) {
            const tooltipsData = [];
            const matchGame = (data) => {
              if (has.call(data, 'steam') && data.steam.id) {
                const $gameTitle = $node.find(`.order-item .game-name:contains(${data.name})`).filter((index, name) => data.name === name.textContent.trim());
                const $orderItem = $gameTitle.closest('.order-item');
                const d = {
                  title: data.name,
                  app: parseInt(data.steam.id, 10),
                };

                d.owned = steam.isOwned(d);
                d.wished = steam.isWished(d);

                // check if owned & wished
                if (d.owned) $orderItem.addClass('SBSE-item--owned');
                if (d.wished) $orderItem.addClass('SBSE-item--wished');

                // append Steam store link
                $gameTitle.append(
                  `<span> | </span><a class="SBSE-link-steam_store" href="https://store.steampowered.com/app/${d.app}/" target="_blank">${i18n.get('steamStore')}</a>`,
                  $('<span class="SBSE-icon"></span>').mouseenter(keylolTooltip.show.bind(keylolTooltip)),
                );

                tooltipsData.push(d);

                $orderItem.addClass('SBSE-item--processed SBSE-item--steam').attr('data-gameinfo', JSON.stringify(d));
              }
            };

            matchGame(APIData);
            APIData.bundles.forEach((tier) => {
              tier.games.forEach(matchGame);
            });

            // load Keylol tooltip
            keylolTooltip.load(tooltipsData);
          }
        });
      });
    };
    const $container = container.get('SBSE', handlers);

    $container.find('.SBSE-button').addClass('SBSE-button--narrow'); // narrow buttons
    $container.find('a').attr('href', ''); // dodge from master css selector

    new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        Array.from(mutation.addedNodes).filter(x => x.nodeType === 1).forEach((node) => {
          const $node = $(node);
          const currentURL = location.href;

          // url changed
          if (node.matches('[property="og:url"]')) {
            if (currentURL.includes('/bundle/') ||
              currentURL.includes('/game/') ||
              currentURL.includes('/dlc/')
            ) fetchAPIData(productHandler);
          }

          // order contents loaded
          if ($node.is('.order-item') || $node.children('div.order-bundle-items-container, div.order-item').length > 0) {
            if (currentURL.includes('/orders/')) {
              // insert container
              const $anchor = $('.account-content h3');

              if ($('.SBSE_container').length === 0 && $anchor.length > 0) {
                $anchor.parent().css({
                  'max-width': '100%',
                  'flex-basis': 'auto',
                });
                $anchor.eq(0).before($container);
              }
            }

            if (currentURL.includes('/product-library')) {
              // insert container
              const $anchor = $('.key-list-container');

              if ($('.SBSE_container').length === 0 && $anchor.length > 0) $anchor.eq(0).before($container);
            }

            process($node);
          }
        });
      });
    }).observe($('html')[0], {
      childList: true,
      subtree: true,
    });
  },
  humblebundle() {
    // inject css
    GM_addStyle(`
      .SBSE-container__content__model > div { position: relative; }
      .SBSE-container__content__model > textarea {
        border: 1px solid #CFCFCF;
        border-radius: 5px;
        color: #4a4c45;
        text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
      }
      .SBSE-button {
        width: 70px;
        border: 1px solid #C9CCD3;
        border-radius: 3px;
        background-color: #C5C5C5;
        background: linear-gradient(to top, #cacaca, #e7e7e7);
        color: #4a4c45 !important;
      }
      .SBSE-button:hover {
        border: 1px solid #b7bac0;
        background-color: #fafcff;
        color: #555961 !important;
      }
      .SBSE-button--narrow.SBSE-button--working { width: 76px; padding-right: 36px; }
      .SBSE-button-setting { position: absolute; right: 0; }
      .SBSE-item--owned .sr-unredeemed-steam-button {
        background-color: #F3F3F3;
        background: linear-gradient(to top, #E8E8E8, #F6F6F6);
      }/*
      .SBSE-item--owned .heading-text h4 > span:not(.steam-owned):last-child::after {
        content: '\\f085';
        font-family: hb-icons;
        color: #17A1E5;
      }*/
      .SBSE-activationRestrictions-title {
        margin: 0 0 5px;
        display: flex;
        positon: relative;
        cursor: pointer;
      }
      .SBSE-activationRestrictions-title::before, .SBSE-activationRestrictions-title::after { padding: 0 5px; }
      .SBSE-activationRestrictions-title::before { content: '+'; display: none; order: 2; }
      .SBSE-activationRestrictions-title::after { content: '-'; display: block; order: 3; }
      .SBSE-activationRestrictions-details p { margin: 0; }
      .SBSE-activationRestrictions-details .highlight { color: crimson; }
      .SBSE-activationRestrictions--collapsed > h5::before { display: block; }
      .SBSE-activationRestrictions--collapsed > h5::after { display: none; }
      .SBSE-activationRestrictions--collapsed > div { display: none; }
      .swal2-icon-text { font-size: inherit; }
      .flag-icon { width: 4em; height: 3em; border-radius: 3px; }
      .flag-icon-unknown { border: 1px solid; text-align: center; line-height: 3em; }
      .key-redeemer:not(:first-child) h4 { margin-top: 50px; }
      .key-redeemer h4 { position: relative; margin-bottom: 10px; }
      .key-redeemer .SBSE-icon { position: absolute; top: 50%; margin-top: -10px; }
    `);

    let gamekey;
    const atDownload = location.pathname === '/downloads';
    const fetchKey = async ($node, machineName, callback) => {
      if (gamekey) {
        const res = await fetch('https://www.humblebundle.com/humbler/redeemkey', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            Origin: 'https://www.humblebundle.com',
            Referer: location.href,
          },
          body: `keytype=${machineName}&key=${gamekey}&keyindex=0`,
          credentials: 'same-origin',
        });

        if (res.ok) {
          const d = await res.json();

          if (d.success) {
            $node.closest('.container').html(`
              <div title="${d.key}" class="js-keyfield keyfield redeemed enabled">
                <div class="keyfield-value">${d.key}</div>
                <a class="steam-redeem-button js-steam-redeem-button" href="https://store.steampowered.com/account/registerkey?key=${d.key}" target="_blank">
                  <div class="steam-redeem-text">Redeem</div>
                  <span class="tooltiptext">Redeem on Steam</span>
                </a>
                <div class="spinner-icon" aria-label="Loading">
                  <i class="hb hb-spinner hb-spin"></i>
                </div>
              </div>
            `);
          } else swal(i18n.get('failTitle'), JSON.stringify(d), 'error');
        } else $node.click();

        if (typeof callback === 'function') callback();
      } else $node.click();
    };
    const handlers = {
      extract() {
        const bundleTitle = $('title').text().split(' (').shift();
        const data = {
          title: bundleTitle,
          filename: `Humble Bundle ${bundleTitle} Keys`,
          items: [],
        };

        $('.keyfield.redeemed .keyfield-value').each((i, ele) => {
          const $ele = $(ele);
          const key = $ele.text().trim();

          if (key) {
            const d = JSON.parse($ele.closest('.SBSE-item--processed').attr('data-gameinfo') || '{}');

            if (Object.keys(d).length === 0) {
              const $titleEle = $ele.closest(atDownload ? '.container' : '.redeemer-cell').prev().find('h4');

              d.title = $titleEle.contents().eq(0).text().trim();
            }

            d.key = key;

            activator.pushKeyDetails(d);
            data.items.push(d);
          }
        });

        return data;
      },
      reveal(e) {
        const $revealBtn = $(e.currentTarget);
        const selected = $('.SBSE-select-filter').val() || 'All';
        const handler = ($games, callback) => {
          const game = $games.shift();

          if (game) {
            const $game = $(game);
            const machineName = $game.closest('.key-redeemer').attr('data-machineName');
            const d = JSON.parse($(game).closest('.SBSE-item--processed').attr('data-gameinfo') || '{}');

            if (atDownload && machineName) {
              if (selected === 'All' || (selected === 'Owned' && d.owned) || (selected === 'NotOwned' && !d.owned)) {
                fetchKey($game, machineName, () => {
                  handler($games, callback);
                });
              } else setTimeout(handler.bind(null, $games, callback), 1);
            } else {
              game.click();
              $('.sr-warning-modal-confirm-button').click();

              setTimeout(handler.bind(null, $games, callback), 200);
            }
          } else callback();
        };

        $revealBtn.addClass('SBSE-button--working');

        handler($('.key-redeemer.SBSE-item--steam .keyfield:not(.redeemed)'), () => {
          $revealBtn.removeClass('SBSE-button--working');
          $('.SBSE-button-retrieve').click();
        });
      },
    };
    const process = async ($node) => {
      gamekey = new URLSearchParams(location.search).get('key');
      let json = GM_getValue(gamekey, '');

      if (json.length === 0) {
        const res = await fetch(`https://www.humblebundle.com/api/v1/order/${gamekey}?all_tpkds=true`, {
          method: 'GET',
          credentials: 'same-origin',
        });

        if (res.ok) json = await res.text();
      }

      try {
        const data = JSON.parse(json);
        const tooltipsData = [];

        data.tpkd_dict.all_tpks.forEach((game) => {
          const $keyRedeemer = $node.find(`.key-redeemer:has(.heading-text[data-title="${game.human_name.replace(/"/g, '\\"')}"])`);

          if ($keyRedeemer.length > 0) {
            if (game.key_type === 'steam') {
              $keyRedeemer.addClass('SBSE-item--steam');

              const d = {
                title: game.human_name,
                app: parseInt(game.steam_app_id, 10),
                sub: parseInt(game.steam_package_id, 10),
              };

              d.owned = steam.isOwned(d);
              d.wished = steam.isWished(d);

              // apply owned effect on game title
              if (d.owned) $keyRedeemer.addClass('SBSE-item--owned');
              if (d.wished) $keyRedeemer.addClass('SBSE-item--wished');

              // store data
              $keyRedeemer.attr({
                'data-machineName': game.machine_name,
                'data-humanName': game.human_name,
                'data-gameinfo': JSON.stringify(d),
              });

              // append Steam store link
              const $target = $keyRedeemer.find('h4 > span').eq(0);

              if (d.app > 0) {
                $target.after(`<span> | </span><a class="SBSE-link-steam_store" href="https://store.steampowered.com/app/${d.app}/" target="_blank">${i18n.get('steamStore')}</a>`);
              }
              if (d.sub > 0) {
                $target.after(`<span> | </span><a class="SBSE-link-steam_db" href="https://steamdb.info/sub/${d.sub}/" target="_blank">Steam DB</a>`);
              }

              tooltipsData.push(d);
            }

            // activation restrictions
            const $container = $('<div class="SBSE-activationRestrictions"></div>');
            const $title = $(`<h5 class="SBSE-activationRestrictions-title">${i18n.get('HBActivationRestrictions')}</h5>`);
            const $details = $('<div class="SBSE-activationRestrictions-details"></div>');
            const disallowed = game.disallowed_countries.map(c => config.get('highlightedRegions').includes(c) ? `<span class="highlight">${ISO2.get(c)}</span>` : ISO2.get(c));
            const exclusive = game.exclusive_countries.map(c => config.get('highlightedRegions').includes(c) ? `<span class="highlight">${ISO2.get(c)}</span>` : ISO2.get(c));
            const comma = config.get('language').includes('chinese') ? '、' : ', ';

            if (disallowed.length > 0) $details.append(`<p>${i18n.get('HBDisallowedCountries')}<br>${disallowed.join(comma)}</p>`);
            if (exclusive.length > 0) $details.append(`<p>${i18n.get('HBExclusiveCountries')}<br>${exclusive.join(comma)}</p>`);
            if (disallowed.length > 0 || exclusive.length > 0) {
              $container.append($title, $details);
              $keyRedeemer.find('.heading-text').after($container);
              $title.on('click', () => {
                $container.toggleClass('SBSE-activationRestrictions--collapsed');
              });
            }

            $keyRedeemer.addClass('SBSE-item--processed');
          }
        });

        // override default popups
        document.addEventListener('click', (e) => {
          const $target = $(e.target).closest('.keyfield:not(.redeemed, .redeemed-gift)');
          const $keyRedeemer = $target.closest('.key-redeemer.SBSE-item--steam');
          const machineName = $keyRedeemer.attr('data-machineName');

          if ($target.length > 0 && $keyRedeemer.length > 0 && machineName) {
            e.stopPropagation();

            if ($keyRedeemer.hasClass('SBSE-item--owned')) {
              swal({
                title: i18n.get('HBAlreadyOwned'),
                text: i18n.get('HBRedeemAlreadyOwned').replace('%title%', $keyRedeemer.attr('data-humanName')),
                type: 'question',
                showCancelButton: true,
              }).then((result) => {
                if (result.value) fetchKey($target, machineName);
              });
            } else fetchKey($target, machineName);
          }
        }, true);

        // load Keylol tooltip
        keylolTooltip.load(tooltipsData);
      } catch (e) {
        throw e;
      }
    };
    const $container = container.get('SBSE', handlers);
    const $keyManager = $('.js-key-manager-holder');

    // narrow buttons
    $container.find('.SBSE-button').addClass('SBSE-button--narrow');

    // at home page
    if ($keyManager.length > 0) {
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          Array.from(mutation.addedNodes).forEach((addedNode) => {
            if (addedNode.className === 'header') {
              observer.disconnect();
              $(addedNode).after($container);
            }
          });
        });
      });

      observer.observe($keyManager[0], {
        childList: true
      });
      // at download page
    } else {
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          Array.from(mutation.addedNodes).forEach(async (addedNode) => {
            const $node = $(addedNode);

            if ($node.hasClass('key-list') || $node.find('.key-list').length > 0) {
              observer.disconnect();
              $node.closest('.whitebox-redux').before($container);

              // fetch game heading & wrap heading
              $node.find('.heading-text > h4').each((i, heading) => {
                heading.parentElement.dataset.title = heading.innerText.trim();
                $(heading.firstChild).wrap('<span/>');
                $(heading).append(
                  $('<span class="SBSE-icon"></span>').mouseenter(keylolTooltip.show.bind(keylolTooltip)),
                );
              });

              // fetch & process key data
              process($node);
            }
          });
        });
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    }

    // append user's region
    const countryCode = unsafeWindow.models.request.country_code;

    if (countryCode) {
      const code = countryCode.toLowerCase();
      const countryName = ISO2.get(countryCode);
      const $flag = $(`<span class="flag-icon flag-icon-unknown" tooltip="${i18n.get('HBCurrentLocation')}?"></span>`);

      if (GM_getResourceText('flagIcon').includes(`${code}.svg`)) {
        $flag.toggleClass(`flag-icon-unknown flag-icon-${code}`).attr('tooltip', i18n.get('HBCurrentLocation') + countryName);
      } else $flag.text('?');

      $('.navbar-content').prepend($flag);
    }
  },
  dailyindiegame() {
    const MPHideList = JSON.parse(GM_getValue('SBSE_DIGMPHideList') || '[]');
    const pathname = location.pathname;

    if (pathname.includes('/account_page') || pathname.includes('/account_update')) {
      // force sync library
      steam.sync([{
        key: 'library'
      }]);

      // update DIG balance
      const balanceText = $('a[href*="transactionhistory.html"]').eq(0).closest('div').text().match(/\$\d+\.\d+/);
      const balance = balanceText ? parseInt(balanceText[0].replace(/\D/g, ''), 10) : '';

      if (!isNaN(balance)) GM_setValue('SBSE_DIGBalance', balance);

      // inject css
      GM_addStyle(`
        .SBSE-container { padding: 5px; border: 1px solid #424242; }
        .SBSE-container__nav__item--show {
          border-bottom: 1px solid #FD5E0F;
          color: #FD5E0F;
        }
        .SBSE-container__content__model > textarea { border: 1px solid #000; }
        .SBSE-button {
          border: none;
          background-color: #FD5E0F;
          color: rgb(49, 49, 49);
          font-family: Ropa Sans;
          font-size: 15px;
          font-weight: 600;
        }
      `);

      const handlers = {
        extract() {
          const data = {
            title: 'DailyIndieGame Keys',
            filename: 'DailyIndieGame Keys',
            items: [],
          };

          $('#TableKeys tr').each((i, tr) => {
            const $tds = $(tr).children();
            const key = $tds.eq(4).text().trim();

            if (key.includes('-')) {
              const d = {
                title: $tds.eq(2).text().trim(),
                key,
                marketListing: $tds.eq(6).text().includes('Cancel trade'),
              };

              activator.pushKeyDetails(d);
              data.items.push(d);
            }
          });

          return data;
        },
        reveal() {
          const $form = $('#form3');

          $('#quickaction').val(1);
          $.ajax({
            method: 'POST',
            url: $form.attr('action'),
            data: $form.serializeArray(),
            success() {
              location.reload();
            },
          });
        },
      };
      const $container = container.get('SBSE', handlers);

      $container.find('.SBSE-button-export, .SBSE-select-filter').remove();
      $container.find('label:has(.SBSE-checkbox-join)').after(`
        <label><input type="checkbox" class="SBSE-checkbox-marketListings">${i18n.get('checkboxMarketListings')}</label>
      `); // append checkbox for market keys

      $('#TableKeys').eq(0).before($container);

      // rate all positive
      const $awaitRatings = $('a[href^="account_page_0_ratepositive"]');

      if ($awaitRatings.length > 0) {
        $('#TableKeys td:contains(Rate TRADE)').text(i18n.get('DIGRateAllPositive')).css('cursor', 'pointer').click(() => {
          $awaitRatings.each(async (i, a) => {
            const res = await fetch(a.href, {
              method: 'GET',
              credentials: 'same-origin',
            });

            if (res.ok) $(a).parent('td').html('<span class="DIG3_14_Orange">Positive</span>');
          });
        });
      }
      // DIG Menu
    } else if (pathname.includes('/account_digstore') ||
      pathname.includes('/account_trades') ||
      pathname.includes('/account_tradesXT') ||
      pathname.includes('/store_update') ||
      pathname.includes('/storeXT_update') ||
      pathname.includes('/site_content_marketplace')) {
      // inject css styles
      GM_addStyle(`
        body.hideOwned .SBSE-item--owned,
        body.hideOwned .SBSE-item--owned + .DIGMenu-searchResults { display: none; }
        .headerRow > td:first-child { padding-left: 0; }
        .headerRow > td:last-child { padding-right: 0; }
        .DIGMenu > * { margin-right: 10px; padding: 4px 8px !important; cursor: pointer; }
        .DIG-row { height: 30px; }
        .DIGMenu button { padding: 4px 8px; outline: none; cursor: pointer; }
        .DIG-row--checked { background-color: #222; }
        .DIGMenu-searchResults td { padding: 0 }
        .DIGMenu-searchResults iframe {
          width: 100%; height: 300px;
          display: none;
          background-color: white;
          border: none;
        }
        .SBSE-item--owned .DIG3_14_Gray { color: #9ccc65; }
        .SBSE-item--wished .DIG3_14_Gray { color: #29b6f6; }
        .SBSE-item--ignored .DIG3_14_Gray { text-decoration: line-through; }
        .DIG2content select { max-width: 200px; }
        #DIGSelectAll { display: none; }
        #DIGSelectAll + span { display: inline-block; }
        #DIGSelectAll ~ span:last-child { display: none; }
        #DIGSelectAll:checked + span { display: none; }
        #DIGSelectAll:checked ~ span:last-child { display: inline-block; }
        .showOwnedListings { color: #FD5E0F; }
        .showOwnedListings > label { vertical-align: text-bottom; }
        .showOwnedListings input:checked + .SBSE-switch__slider { background-color: #FD5E0F; }
        .DIGBalanceDetails > span { margin-right: 20px; }
        .DIG__edit_balance {
          display: inline-block;
          position: relative;
          transform: rotate(45deg);
          cursor: pointer;
        }
        .DIG__edit_balance > span {
          display: inline-block;
        }
        .DIG__edit_balance .tip {
          width: 0; height: 0;
          position: absolute;
          top: 13px;
          border-left: 2px solid transparent;
          border-right: 2px solid transparent;
          border-top: 3px solid #999;
        }
        .DIG__edit_balance .body {
          width: 4px; height: 12px;
          background-color: #999;
        }
        .DIG__edit_balance .rubber {
          width: 4px; height: 2px;
          position: absolute;
          top: -3px;
          background-color: #999;
          top: -3px;
        }
      `);

      swal.showLoading();

      // append menu buttons
      const $target = $('#form3').closest('tr').children().eq(0);
      const $DIGMenu = $(`
        <div class="DIGMenu">
          <label class="DIGSelectAll DIG3_Orange_15_Form">
            <input type="checkbox" id="DIGSelectAll">
            <span>${i18n.get('DIGMenuSelectAll')}</span>
            <span>${i18n.get('DIGMenuSelectCancel')}</span>
          </label>
          <span class="DIGButtonPurchase DIG3_Orange_15_Form">${i18n.get('DIGMenuPurchase')}</span>
          <label class="showOwnedListings">
            <label class="SBSE-switch SBSE-switch--small">
              <input type="checkbox" id="showOwnedListings" checked>
              <span class="SBSE-switch__slider"></span>
            </label>
            <span>${i18n.get('owned')}</span>
          </label>
        </div>
            `);

      if ($target.children().length > 0) {
        const $tr = $('<tr/>');

        $tr.append($target.clone());
        $target.parent().before($tr);
      }

      $target.empty().append($DIGMenu);
      $target.parent().addClass('headerRow');

      // bind button event
      $('.DIGButtonPurchase').click(() => {
        let balance = GM_getValue('SBSE_DIGBalance');
        const $games = $('.DIG-row--checked:visible');

        swal({
          title: i18n.get('DIGButtonPurchasing'),
          html: '<p></p>',
          onOpen: () => {
            swal.showLoading();
          },
        });

        (async function purchaseHandler() {
          const game = $games.shift();

          if (game) {
            const $game = $(game);
            const id = $game.attr('data-id');
            const price = parseInt($game.attr('data-price'), 10);
            const title = $game.attr('data-title');

            if (title.length > 0) swal.getContent().querySelector('p').textContent = title;

            if (id && price > 0) {
              if (balance - price >= 0) {
                let url = `${location.origin}/account_buy.html`;
                const requestInit = {
                  method: 'POST',
                  headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                  },
                  body: `quantity=1&xgameid=${id}&xgameprice1=${price}&send=Purchase`,
                  mode: 'same-origin',
                  credentials: 'same-origin',
                  cache: 'no-store',
                  referrer: `${location.origin}/account_buy_${id}.html`,
                };

                if (pathname === '/account_trades.html' || pathname === '/account_tradesXT.html' || pathname === '/site_content_marketplace.html') {
                  url = `${location.origin}/account_buytrade_${id}.html`;
                  requestInit.body = `gameid=${id}&send=Purchase`;
                  requestInit.referrer = url;
                }

                const res = await fetch(url, requestInit);

                if (res.ok) {
                  $game.click();
                  balance -= price;

                  $('.DIG__current_balance').attr('data-value', balance);
                }

                purchaseHandler();
              } else {
                swal({
                  title: i18n.get('failTitle'),
                  text: i18n.get('DIGInsufficientFund'),
                  type: 'error',
                });
              }
            } else purchaseHandler();
          } else {
            GM_setValue('SBSE_DIGBalance', balance);
            swal({
              title: i18n.get('successTitle'),
              text: i18n.get('DIGFinishedPurchasing'),
              type: 'success',
            });
          }
        }());
      });
      $('#DIGSelectAll').on('change', (e) => {
        const checked = e.delegateTarget.checked;
        let total = 0;

        $('.DIG-row:visible').toggleClass('DIG-row--checked', checked);

        if (checked) {
          total = $('.DIG-row--checked:visible').map((i, row) => parseInt(row.dataset.price, 10)).get().reduce((a, b) => a + b);
        }

        $('.DIG_total_amount').attr('data-value', total);
      });
      $('#showOwnedListings').on('change', (e) => {
        const showOwnedListings = e.delegateTarget.checked;
        const $rows = $('.DIG-row--checked.SBSE-item--owned');

        $('body').toggleClass('hideOwned', !showOwnedListings);
        GM_setValue('DIGShowOwnedListings', showOwnedListings);

        if (!showOwnedListings && $rows.length > 0) {
          const total = $rows.map((i, row) => parseInt(row.dataset.price, 10)).get().reduce((a, b) => a + b);

          $rows.removeClass('DIG-row--checked');
          $('.DIG_total_amount').attr('data-value', (i, value) => parseInt(value, 10) - total);
        }
      });

      // menu settings
      $('#showOwnedListings').prop('checked', GM_getValue('DIGShowOwnedListings', true)).change();

      // append sync time and event
      const seconds = Math.round((Date.now() - steam.lastSync('library')) / 1000);

      $target.closest('table').before(`
        <span> ${i18n.get('lastSyncTime').replace('%seconds%', seconds)}</span>
      `);

      // append balance details
      $target.closest('table').before(`
        <div class="DIGBalanceDetails">
          <span>${i18n.get('DIGCurrentBalance')}$<span class="DIG__current_balance" data-value="0">0.00</span></span>
          <span class="DIG__edit_balance">
            <span class="tip"></span>
            <span class="body"></span>
            <span class="rubber"></span>
          </span>
          <span>${i18n.get('DIGTotalAmount')}$<span class="DIG_total_amount" data-value="0">0.00</span></span>
        </div>
      `);

      // bind balance details event
      $('.DIGBalanceDetails span[data-value]').each((i, span) => {
        new MutationObserver((mutations) => {
          mutations.forEach((mutation) => {
            if (mutation.attributeName === 'data-value') {
              const target = mutation.target;

              target.textContent = (target.dataset.value / 100).toFixed(2);
            }
          });
        }).observe(span, {
          attributes: true
        });
      });

      $('.DIG__edit_balance').on('click', () => {
        swal({
          title: i18n.get('DIGEditBalance'),
          input: 'number',
          inputPlaceholder: i18n.get('DIGPoint'),
          inputAttributes: {
            min: 1
          },
          showCancelButton: true,
        }).then((result) => {
          if (!isNaN(result.value)) {
            const balance = Math.trunc(result.value);

            GM_setValue('SBSE_DIGBalance', balance);
            $('.DIG__current_balance').attr('data-value', balance);
          }
        });
      });

      // bind row event
      const $totalAmount = $('.DIG_total_amount');
      const getPrice = ($tr) => {
        let p = 0;
        const $DIGPoints = $tr.find('td:contains( DIG Points)');

        if ($DIGPoints.length === 1) p = $DIGPoints.text();
        else {
          const tds = $tr.children('td').get();

          for (let j = tds.length - 1; j >= 0; j -= 1) {
            const t = tds[j].textContent.trim();

            if (t.startsWith('$')) {
              p = t.replace(/\D/g, '');
              break;
            }
          }
        }

        return parseInt(p, 10);
      };

      $('a[href^="account_buy"]').eachAsync((ele) => {
        const $ele = $(ele);
        const $tr = $ele.closest('tr');
        const $title = $tr.children('td').eq(pathname.includes('/account_digstore') ? 3 : 1);

        const id = $ele.attr('href').replace(/\D/g, '');
        const title = $title.text().trim();
        const price = getPrice($tr);
        const onclickHandler = $tr.attr('onclick');

        // setup row data & event
        $tr.attr({
          'data-id': id,
          'data-title': title,
          'data-price': price,
        });
        $tr.addClass('DIG-row').on('click', () => {
          $tr.toggleClass('DIG-row--checked');
          $totalAmount.attr('data-value', (index, value) => parseInt(value, 10) + (price * ($tr.hasClass('DIG-row--checked') ? 1 : -1)));
        });

        // re-locate onclick handler
        if (pathname.includes('/site_content_marketplace') && onclickHandler) {
          $title.wrapInner(
            $('<span></span>').attr('onclick', onclickHandler),
          );
          $tr.removeAttr('onclick');
        }

        // check if owned
        const $a = $tr.find('a[href*="steampowered"]');
        const d = {};
        let steamID = 0;

        if ($a.length === 1) {
          const data = $a[0].pathname.slice(1).split('/');

          steamID = parseInt(data[1], 10);
          d[data[0]] = steamID;
        } else if (onclickHandler.includes('site_gamelisting_')) {
          steamID = parseInt(onclickHandler.match(/_(\d+)\./)[1], 10);
          d.app = steamID;
        }

        if (steam.isOwned(d)) $tr.addClass('SBSE-item--owned');
        if (steam.isWished(d)) $tr.addClass('SBSE-item--wished');
        if (steam.isIgnored(d)) $tr.addClass('SBSE-item--ignored');

        // no appID found, pre-load Google search result
        if (steamID === -1 && !MPHideList.includes(id)) {
          const $game = $a.find('span');
          const gameTitle = encodeURIComponent($game.text().trim()).replace(/%20/g, '+');
          const map = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;',
          };

          GM_xmlhttpRequest({
            method: 'GET',
            url: `https://www.google.com/search?q=steam+${gameTitle}`,
            onload: (res) => {
              let html = res.responseText;

              // inset style
              const index = html.indexOf('</head>');
              const style = `
                <style>
                  body { overflow-x: hidden; }
                  .sfbgx, #sfcnt, #searchform, #top_nav, #appbar, #taw { display: none; }
                  #center_col { margin-left: 0 !important; }
                </style>
              `;
              html = html.slice(0, index) + style + html.slice(index);

              // stripe script tags
              html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');

              // manipulate urls
              html = html
                .replace(/\/images\//g, 'https://www.google.com/images/')
                .replace(/\/url\?/g, 'https://www.google.com/url?');

              $tr.after(`
                <tr class="DIGMenu-searchResults">
                  <td colspan="11"><iframe sandbox="allow-scripts" srcdoc='${html.replace(/[&<>"']/g, m => map[m])}'></frame></td>
                </tr>
              `);
            },
          });

          $game.unwrap('a').css({
            cursor: 'pointer',
            color: 'red',
          }).click((e) => {
            e.stopPropagation();

            $tr.next('.DIGMenu-searchResults').find('iframe')
              .slideToggle('fast');
          });
        }

        // remove row if manually hid
        if (MPHideList.includes(id)) $tr.remove();
        else {
          // append manual hide feature
          $tr.children().eq(0).attr('title', i18n.get('DIGClickToHideThisRow')).click((e) => {
            e.stopPropagation();

            if (id > 0) {
              MPHideList.push(id);
              GM_setValue('SBSE_DIGMPHideList', JSON.stringify(MPHideList));

              $tr.remove();
            }
          });
        }
      }, () => {
        swal({
          titleText: i18n.get('successTitle'),
          text: i18n.get('loadingSuccess'),
          type: 'success',
          timer: 3000,
        });
      });

      // setup current balance
      $('.DIG__current_balance').attr('data-value', GM_getValue('SBSE_DIGBalance', 0));
      // extension for creating trade at market place
    } else if (pathname === '/site_content_giveaways.html') {
      swal.showLoading();

      // inject css styles
      GM_addStyle(`
        body.hideOwned .SBSE-item--owned { display: none; }
        .DIGMenu > * { margin-right: 10px; padding: 4px 0 !important; cursor: pointer; }
        .DIG-row { height: 30px; }
        .SBSE-item--owned .DIG4-Orange-14 { color: #9ccc65; }
        .SBSE-item--wished .DIG4-Orange-14 { color: #29b6f6; }
        .SBSE-item--ignored .DIG4-Orange-14 { text-decoration: line-through; }
        .showOwnedListings { display: inline-block; color: #FD5E0F; }
        .showOwnedListings > label { vertical-align: text-bottom; }
        .showOwnedListings input:checked + .SBSE-switch__slider { background-color: #FD5E0F; }
      `);

      // append menu buttons
      const $target = $('a[href^="site_content_giveaways_"]').eq(0).closest('table#DIG2TableGray');
      const $DIGMenu = $(`
        <div class="DIGMenu">
          <label class="showOwnedListings">
            <label class="SBSE-switch SBSE-switch--small">
              <input type="checkbox" id="showOwnedListings" checked>
              <span class="SBSE-switch__slider"></span>
            </label>
            <span>${i18n.get('owned')}</span>
          </label>
        </div>
      `);

      $target.before($DIGMenu);

      // bind button event
      $('.DIGButtonPurchase').click(() => {
        let balance = GM_getValue('SBSE_DIGBalance');
        const $games = $('.DIG-row--checked:visible');

        swal({
          title: i18n.get('DIGButtonPurchasing'),
          html: '<p></p>',
          onOpen: () => {
            swal.showLoading();
          },
        });

        (async function purchaseHandler() {
          const game = $games.shift();

          if (game) {
            const $game = $(game);
            const id = $game.attr('data-id');
            const price = parseInt($game.attr('data-price'), 10);
            const title = $game.attr('data-title');

            if (title.length > 0) swal.getContent().querySelector('p').textContent = title;

            if (id && price > 0) {
              if (balance - price >= 0) {
                let url = `${location.origin}/account_buy.html`;
                const requestInit = {
                  method: 'POST',
                  headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                  },
                  body: `quantity=1&xgameid=${id}&xgameprice1=${price}&send=Purchase`,
                  mode: 'same-origin',
                  credentials: 'same-origin',
                  cache: 'no-store',
                  referrer: `${location.origin}/account_buy_${id}.html`,
                };

                if (pathname === '/account_trades.html' || pathname === '/account_tradesXT.html' || pathname === '/site_content_marketplace.html') {
                  url = `${location.origin}/account_buytrade_${id}.html`;
                  requestInit.body = `gameid=${id}&send=Purchase`;
                  requestInit.referrer = url;
                }

                const res = await fetch(url, requestInit);

                if (res.ok) {
                  $game.click();
                  balance -= price;

                  $('.DIG__current_balance').attr('data-value', balance);
                }

                purchaseHandler();
              } else {
                swal({
                  title: i18n.get('failTitle'),
                  text: i18n.get('DIGInsufficientFund'),
                  type: 'error',
                });
              }
            } else purchaseHandler();
          } else {
            GM_setValue('SBSE_DIGBalance', balance);
            swal({
              title: i18n.get('successTitle'),
              text: i18n.get('DIGFinishedPurchasing'),
              type: 'success',
            });
          }
        }());
      });
      $('#showOwnedListings').on('change', (e) => {
        const showOwnedListings = e.delegateTarget.checked;

        $('body').toggleClass('hideOwned', !showOwnedListings);
        GM_setValue('DIGShowOwnedListings', showOwnedListings);
      });

      // menu settings
      $('#showOwnedListings').prop('checked', GM_getValue('DIGShowOwnedListings', true)).change();

      // append sync time and event
      const seconds = Math.round((Date.now() - steam.lastSync('library')) / 1000);

      $DIGMenu.prepend(`
        <span class="DIG4-Gray-13"> ${i18n.get('lastSyncTime').replace('%seconds%', seconds)}</span>
      `);

      $('a[href^="site_gamelisting_"]').eachAsync((ele) => {
        const $ele = $(ele);
        const $tr = $ele.closest('tr');
        const $title = $tr.children('td').eq(1);

        const id = $ele.attr('href').replace(/\D/g, '');
        const title = $title.text().trim();

        // setup row data & event
        $tr.addClass('DIG-row').attr({
          'data-id': id,
          'data-title': title,
        });

        // check if owned
        const d = {
          app: parseInt(id, 10)
        };

        if (steam.isOwned(d)) $tr.addClass('SBSE-item--owned');
        if (steam.isWished(d)) $tr.addClass('SBSE-item--wished');
        if (steam.isIgnored(d)) $tr.addClass('SBSE-item--ignored');
      }, () => {
        swal({
          titleText: i18n.get('successTitle'),
          text: i18n.get('loadingSuccess'),
          type: 'success',
          timer: 3000,
        });
      });
    } else if (pathname === '/account_createtrade.html') {
      const $form = $('#form_createtrade');

      // create trade page
      if ($form.length > 0) {
        // trim input field
        const $gameTitle = $form.find('input[name="typeahead"]');
        const $steamKey = $form.find('input[name="STEAMkey"]');

        $gameTitle.blur(() => {
          unsafeWindow.jQuery('input.typeahead').typeahead('setQuery', $gameTitle.val().trim());
        });
        $steamKey.blur((e) => {
          const $self = $(e.delegateTarget);
          const key = $self.val().match(regKey);

          if (key) $self.val(key[0]);
        });
        $steamKey.attr({
          size: 50,
          maxlength: 200,
        });

        // search for current market price when click dropdown menu
        const $searchResult = $('<div/>');

        $gameTitle.closest('table').after($searchResult);
        $searchResult.before(`<h3>${i18n.get('DIGMarketSearchResult')}</h3>`);

        $('.tt-dropdown-menu').click(async () => {
          $searchResult.empty();

          const res = await fetch(`${location.origin}/account_tradesXT.html`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded'
            },
            body: `search=${encodeURIComponent($gameTitle.val()).replace(/%20/g, '+')}&button=SEARCH`,
            credentials: 'same-origin',
          });
          const result = res.ok ? $(await res.text()).find('#TableKeys') : 'Network response was not ok.';

          $searchResult.append(result);
        });

        // apply last input price
        const lastPrice = GM_getValue('SBSE_DIGLastPrice', 20);
        const $priceField = $('input[name=price]');

        $priceField.val(lastPrice).trigger('input');
        $('#form_createtrade').submit(() => {
          const price = parseInt($priceField.val(), 10);

          if (price !== lastPrice) GM_setValue('SBSE_DIGLastPrice', price);
        });
        // result page
      } else {
        GM_addStyle(`
          .check.icon {
            width: 42px; height: 24px;
            margin: 12px 0 5px 9px;
            border-bottom: solid 3px currentColor;
            border-left: solid 3px currentColor;
            transform: rotate(-45deg);
            color: #5cb85c;
          }
          .remove.icon { color: #d9534f; margin-left: 9px; margin-top: 30px; }
          .remove.icon:before, .remove.icon:after {
            width: 45px; height: 3px;
            position: absolute;
            content: '';
            background-color: currentColor;
            transform: rotate(45deg);
          }
          .remove.icon:after { transform: rotate(-45deg); }
        `);

        const $anchor = $('td.DIG3_14_Gray > table:first-child');
        const IsSucceed = !!$('td.DIG3_14_Gray:contains("The game key has been added to the DIG MarketPlace.")').length;

        if (IsSucceed) $anchor.after('<div class="check icon"></div>');
        else $anchor.after('<div class="remove icon"></div>');
      }
    }
  },
  ccyyshop() {
    // inject css
    GM_addStyle(`
      .SBSE-container {
        width: 80%;
        position: relative;
        margin: 0 auto;
        font-size: 16px;
        color: #000;
        z-index: 999;
      }
      .SBSE-container__content__model > textarea {
        background-color: #EEE;
        box-shadow: 0 0 1px 1px rgba(204,204,204,0.5);
        border-radius: 5px;
      }
      .SBSE-container__content__model > div { text-align: left; }
      .SBSE-button {
        width: 80px;
        border: 1px solid #2e6da4;
        border-radius: 5px;
        background-color: #337ab7;
        color: #FFF;
      }
      .SBSE-container label { color: #EEE; }
      .expanded .showOrderMeta {
        display: block !important;
        position: absolute;
        margin-top: -8px;
        right: 265px;
        z-index: 1;
      }
    `);

    const handlers = {
      extract() {
        const data = {
          title: 'CCYYCN Bundle',
          filename: 'CCYYCN Bundle',
          items: [],
        };

        $('.deliver-gkey > *:contains(-)').each((i, ele) => {
          const $ele = $(ele);
          const d = {
            title: $ele.closest('.deliver-game').prev().text().trim(),
            key: $ele.text().trim(),
          };

          activator.pushKeyDetails(d);
          data.items.push(d);
        });

        return data;
      },
      reveal(e) {
        const $revealBtn = $(e.currentTarget);
        const handler = ($games, callback) => {
          const game = $games.shift();

          if (game) {
            game.click();
            setTimeout(handler.bind(null, $games, callback), 300);
          } else callback();
        };

        $revealBtn.addClass('SBSE-button--working');

        handler($('.deliver-btn'), () => {
          $revealBtn.removeClass('SBSE-button--working');
          $('.SBSE-button-retrieve').click();
        });
      },
    };
    const $container = container.get('SBSE', handlers);

    $container.find('.SBSE-select-filter').remove(); // hide filter selector
    $container.find('.SBSE-button').addClass('SBSE-button--narrow'); // narrow buttons

    // insert textarea
    $('.featurette-divider').eq(0).after($container);
  },
  groupees() {
    if (location.pathname.startsWith('/profile/')) {
      // inject css
      GM_addStyle(`
        .SBSE-container__content__model > textarea, .SBSE-button {
          background: transparent;
          border: 1px solid #8cc53f;
          border-radius: 3px;
          color: #8cc53f;
          transition: all 0.8s ease;
        }
        .SBSE-button:hover {
          background-color: #8cc53f;
          color: white;
          text-decoration: none;
        }
        img.product-cover { display: none; }
      `);
      const handlers = {
        extract() {
          const bundleTitle = $('h2').text().trim();
          const data = {
            title: bundleTitle,
            filename: `Groupees ${bundleTitle} Keys`,
            items: [],
          };

          $('.key-block input.code').each((i, ele) => {
            const $ele = $(ele);
            const key = $ele.val();

            if (key.includes('-')) {
              const $titleEle = $ele.closest('tr').prev().find('td:nth-of-type(3)');
              const d = {
                title: $titleEle.text().trim(),
                key,
                used: !!$ele.closest('.key-block').find('.key-status:contains(used)').length,
              };

              activator.pushKeyDetails(d);
              data.items.push(d);
            }
          });

          return data;
        },
        reveal(e) {
          const $revealBtn = $(e.currentTarget);
          const handler = ($games, callback) => {
            const game = $games.shift();

            if (game) {
              game.click();
              setTimeout(handler.bind(null, $games, callback), 300);
            } else callback();
          };

          $revealBtn.addClass('SBSE-button--working');

          const $reveals = $('.product:has(img[title*=Steam]) .reveal-product');
          const timer = $reveals.length > 0 ? 1500 : 0;

          $reveals.click();
          setTimeout(() => {
            handler($('.btn-reveal-key'), () => {
              $revealBtn.removeClass('SBSE-button--working');
              $('.SBSE-button-retrieve').click();
            });
          }, timer);
        },
      };
      const $container = container.get('SBSE', handlers);

      $container.find('.SBSE-select-filter').hide(); // hide filter selector

      // append checkbox for used-key
      $('.SBSE-button-setting').before(`
        <label><input type="checkbox" class="SBSE-checkbox-skipUsed" checked>${i18n.get('checkboxSkipUsed')}</label>
      `);

      // insert container
      $('.table-products').before($container);

      // load details
      $('img[src*="steam.svg"]').each(async (index, ele) => {
        $.ajax({
          url: $(ele).closest('tr').find('.item-link').attr('href'),
          data: {
            v: 1
          },
          dataType: 'script',
        });
      });

      // bind custom event
      $(document).on('activated', (e, key, result) => {
        if (result.success === 1) $(`.btn-steam-redeem[href*=${key}]`).next('.key-usage-toggler').click();
      });
    } else {
      // inject css
      GM_addStyle(`
        .SBSE-container { margin-bottom: 20px; }
        .SBSE-container__content__model > textarea { background-color: #EEE; border-radius: 3px; }
        .SBSE-button { outline: none !important; }
        .SBSE-button-setting { margin-top: 8px; }
      `);

      const handlers = {
        extract() {
          const bundleTitle = $('.expanded .caption').text().trim();
          const data = {
            title: bundleTitle,
            filename: `Groupees ${bundleTitle} Keys`,
            items: [],
          };

          $('.expanded .code').each((i, ele) => {
            const $ele = $(ele);
            const d = {
              title: $ele.closest('.details').find('h3').text().trim(),
              key: $ele.val(),
              used: $ele.closest('li').find('.usage').prop('checked'),
            };

            activator.pushKeyDetails(d);
            data.items.push(d);
          });

          return data;
        },
        reveal(e) {
          const $revealBtn = $(e.currentTarget);
          const handler = ($games, callback) => {
            const game = $games.shift();

            if (game) {
              game.click();
              setTimeout(handler.bind(null, $games, callback), 300);
            } else callback();
          };

          $revealBtn.addClass('SBSE-button--working');

          const $reveals = $('.product:has(img[title*=Steam]) .reveal-product');
          const timer = $reveals.length > 0 ? 1500 : 0;

          $reveals.click();
          setTimeout(() => {
            handler($('.expanded .reveal'), () => {
              $revealBtn.removeClass('SBSE-button--working');
              $('.SBSE-button-retrieve').click();
            });
          }, timer);
        },
      };
      const $container = container.get('SBSE', handlers);

      // append checkbox for used-key
      $container.find('.SBSE-button-setting').before($(`
        <label><input type="checkbox" class="SBSE-checkbox-skipUsed" checked>${i18n.get('checkboxSkipUsed')}</label>
      `));
      // add buttons style via groupees's class
      $container.find('.SBSE-button').addClass('btn btn-default');

      // insert container
      $('.container > div').eq(1).before($container);

      // append mark all as used button
      new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          Array.from(mutation.addedNodes).forEach((addedNode) => {
            const $orderMeta = $(addedNode).find('.order-meta');

            if ($orderMeta.length > 0) {
              $orderMeta.after(
                $(`<button class="btn btn-default" style="margin-right: 10px;"><b>${i18n.get('markAllAsUsed')}</b></button>`).click(() => {
                  $('.expanded .usage').each((i, checkbox) => {
                    if (!checkbox.checked) checkbox.click();
                  });
                }),
              );
              $orderMeta.parent().addClass('showOrderMeta');
            }
          });
        });
      }).observe($('#profile_content')[0], {
        childList: true
      });

      // bind custom event
      $(document).on('activated', (e, key, result) => {
        if (result.success === 1) $(`li.key:has(input[value=${key}]) .usage`).click();
      });
    }
  },
  agiso() {
    const keys = unique($('body').text().match(regKey));

    if (keys.length > 0) {
      // inject css
      GM_addStyle(`
        .SBSE-container__content__model > textarea { border: 1px solid #AAAAAA; }
        .SBSE-button {
          border: 1px solid #d3d3d3;
          background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x;
          color: #555555;
        }
        .SBSE-button:hover {
          border-color: #999999;
          background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x;
          color: #212121;
        }
      `);

      const handlers = {
        extract() {
          const bundleTitle = $('a[href*="tradeSnap.htm"]').eq(1).text().trim();
          const data = {
            title: bundleTitle,
            filename: `agiso ${bundleTitle} Keys`,
            items: [],
          };

          keys.forEach((key) => {
            data.items.push({
              key
            });
          });

          return data;
        },
      };
      const $container = container.get('SBSE', handlers);

      $container.find('.SBSE-button-reveal, .SBSE-select-filter').remove();

      // insert container
      $('#tabs').eq(0).prepend($container);
    }
  },
  keylol() {
    if (location.pathname.startsWith('/tooltip')) {
      GM_addStyle('body { overflow: hidden; }');
    }
  },
  yuplay() {
    // inject css
    GM_addStyle(`
      .SBSE-container { margin-top: 20px; }
      .SBSE-container__content__model > textarea { background-color: rgb(230, 230, 229); color: rgb(27, 26, 26); }
      .SBSE-container__content__model > div { text-align: left; }
      .SBSE-button {
        width: 80px;
        border: 1px solid #b4de0a;
        background-color: #b4de0a;
        color: #1a1a1a;
      }
      .SBSE-button:hover {
        border: 1px solid #a4ca09;
        background-color: #a4ca09;
      }
      .SBSE-container label { color: #1a1a1a; font-weight: 400; }
      .SBSE-table-appList { margin-bottom: 10px; }
      .SBSE-table-appList td { vertical-align: top; }
      .SBSE-table-appList a { display: block; margin-bottom: 5px; }
      .SBSE-icon { position: relative; top: 5px; }
    `);

    const handlers = {
      extract() {
        const data = {
          title: 'Yuplay Games',
          filename: 'Yuplay Games',
          items: [],
        };

        $('.product-info').each((i, ele) => {
          const $ele = $(ele);
          const d = {
            title: $ele.find('.name').text().trim(),
            key: $ele.next('.keys').find('input').val(),
          };

          activator.pushKeyDetails(d);
          data.items.push(d);
        });

        return data;
      },
    };
    const appListHandler = (data) => {
      if (data.length > 0) {
        const $appList = $('<table class="SBSE-table-appList"></table>');

        $appList.append('<tr><td colspan="2">App List</td></tr>');

        data.forEach((d) => {
          const $row = $('<tr/>');

          $row.append(
            $('<td/>').append($('<span class="SBSE-icon"></span>').mouseenter(keylolTooltip.show.bind(keylolTooltip))),
            $(`<td><a href="https://store.steampowered.com/app/${d.app}" target="_blank">${d.title}</a></td>`),
          );

          d.owned = steam.isOwned(d);
          d.wished = steam.isWished(d);

          if (d.owned) $row.addClass('SBSE-item--owned');
          if (d.wished) $row.addClass('SBSE-item--wished');

          $row.addClass('SBSE-item--processed SBSE-item--steam').attr('data-gameinfo', JSON.stringify(d));

          $appList.append($row);
        });

        $('.list-character').after($appList);

        // load Keylol tooltip
        keylolTooltip.load(data);
      }
    };
    const $container = container.get('SBSE', handlers);

    $container.find('.SBSE-button').addClass('SBSE-button--narrow'); // narrow buttons
    $container.find('.SBSE-button-reveal, .SBSE-select-filter').remove(); // remove reveal

    // insert textarea
    $('.table-light').eq(0).before($container);

    // append info from SteamDB if found subid
    $('.list-character p').each((i, ele) => {
      const $ele = $(ele);
      const text = $ele.text().trim();

      if (text.startsWith('Steam')) {
        const subID = text.match(/\d+/)[0];
        const steamDBUrl = `https://steamdb.info/sub/${subID}/`;
        const steamDBKey = `SBSE_steamDB_sub_${subID}`;
        const steamDBData = GM_getValue(steamDBKey, '');

        $ele.find('span').replaceWith(`<a href="${steamDBUrl}" target="_blank">${subID}</a>`);

        if (steamDBData.length === 0) {
          GM_xmlhttpRequest({
            url: steamDBUrl,
            method: 'GET',
            onload(res) {
              if (res.status === 200) {
                const data = [];

                $(res.response).find('#apps .app').each((j, app) => {
                  const $app = $(app);
                  const d = {
                    title: $app.children('td').eq(2).text().trim(),
                    app: parseInt($app.attr('data-appid'), 10),
                  };

                  data.push(d);
                });

                GM_setValue(steamDBKey, JSON.stringify(data));
                appListHandler(data);
              }
            },
          });
        } else appListHandler(JSON.parse(steamDBData));
      }
    });
  },
  'gama-gama': () => {
    // inject css
    GM_addStyle(`
      .SBSE-container__content__model > textarea { background-color: #ededed; color: #33; border-radius: 4px; }
      .SBSE-button {
        width: 80px; height: 35px;
        border: none; border-radius: 4px;
        background: linear-gradient(to bottom, #47bceb 0, #18a4dd 30%, #127ba6 100%);
        color: #fff;
        box-shadow: 0 1px 3px 1px rgba(0,0,0,.8);
      }
      .SBSE-button { font-family: inherit; font-size: inherit; }
      .SBSE-button:hover { background: linear-gradient(to bottom, #47bceb, #18a4dd); }
    `);

    const handlers = {
      extract() {
        const data = {
          title: 'Gama Gama Games',
          filename: 'Gama Gama Games',
          items: [],
        };

        $('.gift-line').each((i, ele) => {
          const $ele = $(ele);

          $ele.find('.key-list > li').each((j, key) => {
            const d = {
              title: $ele.find('.gift-header').text().trim(),
              key: key.textContent.trim(),
            };

            activator.pushKeyDetails(d);
            data.items.push(d);
          });
        });

        return data;
      },
    };
    const $container = container.get('SBSE', handlers);

    $container.find('.SBSE-button').addClass('SBSE-button--narrow'); // narrow buttons
    $container.find('.SBSE-button-reveal, .SBSE-select-filter').remove(); // remove reveal

    // insert textarea
    $('.user-info').eq(0).after($container);
  },
  plati() {
    let selectedCurrency = GM_getValue('SBSE_selectedCurrency', 'USD');
    let platiCurrency = $('th.product-price select option:selected').text().trim();
    const plati = {
      data: JSON.parse(GM_getValue('SBSE_plati', '{}')),
      save(callback) {
        GM_setValue('SBSE_plati', JSON.stringify(this.data));

        if (typeof callback === 'function') callback();
      },
      set(key, value, callback) {
        this.data[key] = value;
        this.save(callback);
      },
      setItem(id, value, save) {
        this.data.itemData[id] = value;
        if (save) this.save();
      },
      get(key) {
        return has.call(this.data, key) ? this.data[key] : null;
      },
      getItem(id) {
        return has.call(this.data.itemData, id) ? this.data.itemData[id] : null;
      },
      init() {
        if (!has.call(this.data, 'enablePlatiFeature')) this.data.enablePlatiFeature = true;
        if (!has.call(this.data, 'fetchOnStart')) this.data.fetchOnStart = true;
        if (!has.call(this.data, 'infiniteScroll')) this.data.infiniteScroll = true;
        if (!has.call(this.data, 'itemData')) this.data.itemData = {};
        if (!has.call(this.data, 'filterGame')) this.data.filterGame = true;
        if (!has.call(this.data, 'filterDLC')) this.data.filterDLC = true;
        if (!has.call(this.data, 'filterPackage')) this.data.filterPackage = true;
        if (!has.call(this.data, 'filterBundle')) this.data.filterBundle = true;
        if (!has.call(this.data, 'filterOwned')) this.data.filterOwned = true;
        if (!has.call(this.data, 'filterWished')) this.data.filterWished = true;
        if (!has.call(this.data, 'filterIgnored')) this.data.filterIgnored = true;
        if (!has.call(this.data, 'filterNotOwned')) this.data.filterNotOwned = true;
        if (!has.call(this.data, 'filterNotApplicable')) this.data.filterNotApplicable = true;
        if (!has.call(this.data, 'filterNotFetched')) this.data.filterNotFetched = true;

        this.save();
      },
    };
    const infiniteScroll = {
      enabled: plati.get('infiniteScroll'),
      loading: false,
      lastPage: 0,
      reachedLastPage: false,
      pathname: $('head #popup-container + script').text().match(/\/asp\/block_goods.+?\.asp/)[0],
      parameters: {
        idr: 0,
        sort: 'name',
        page: 0,
        rows: 10,
        curr: 'USD',
        lang: unsafeWindow.plang || 'en-US',
      },
      setParameters() {
        const $paging = $('.pages_nav').eq(0).children('a');
        const onclickArguments = $paging.eq(0).attr('onclick').match(/\((.+)\)/);

        if (onclickArguments[1]) {
          const parameters = onclickArguments[1].split(',').map(x => (isNaN(x) ? x.replace(/['"]+/g, '') : parseInt(x, 10)));

          this.parameters.idr = parameters[0];
          this.parameters.sort = parameters[1];
          this.parameters.rows = parameters[3];
          this.parameters.curr = parameters[4];
          this.parameters.page = parseInt($paging.filter('.active').text(), 10) + 1;
          this.lastPage = parseInt($paging.filter(':last-child').text(), 10);
        }

        if (this.pathname) {
          const type = this.pathname.slice(-5, -4);

          this.parameters[`id_${type}`] = location.pathname.includes('/seller/') ? location.pathname.split('/').pop() : this.parameters.idr;
        }
      },
      fetchNextPage: async function fetchNextPage() {
        const $loader = $('.content_center .platiru-loader').eq(0);

        $loader.css('visibility', 'visible');
        this.loading = true;

        const $wrap = $('.SBSE-infiniteScroll-wrap');
        const $table = $wrap.find('table.goods-table');
        const params = this.parameters;
        params.rnd = Math.random();

        if (this.pathname) {
          const res = await fetch(`${this.pathname}?${$.param(params)}`);
          const $resHTML = $(await res.text());
          const $trs = $resHTML.find('tbody > tr');

          if (res.ok && $trs.length > 0) {
            $table.find('tbody').append($trs);

            // refresh paging
            $wrap.siblings('.pages_nav, .sort_by').remove();
            $wrap.after($resHTML.filter('.pages_nav, .sort_by'), $resHTML.find('.goods-table ~ *'));

            params.page += 1;
            this.reachedLastPage = params.page > this.lastPage;
          }
        }

        this.loading = false;
        this.scrollHandler();
        $loader.css('visibility', 'hidden');
      },
      scrollHandler() {
        const $wrap = $('.SBSE-infiniteScroll-wrap');

        if ($('body').is('.enablePlatiFeature.infiniteScroll') &&
          $wrap.length > 0 &&
          this.enabled === true &&
          this.loading === false &&
          this.reachedLastPage === false) {
          const spaceTillBotom = $wrap.prop('scrollHeight') - $wrap.scrollTop() - $wrap.height();

          if (spaceTillBotom < 200) this.fetchNextPage();
        }
      },
      init() {
        if ($('.SBSE-infiniteScroll-wrap').length === 0) {
          $('.goods-table').wrap($('<div class="SBSE-infiniteScroll-wrap"></div>').on('scroll', this.scrollHandler.bind(this)));
        }

        this.scrollHandler();
      },
    };
    const processor = {
      fetchItem: async function fetchItem(queue) {
        const tr = queue.shift();

        if (tr) {
          const $tr = $(tr);
          const url = $tr.attr('data-url');
          const id = parseInt($tr.attr('data-id'), 10);
          const classes = ['SBSE-item--fetching', 'SBSE-item--fetched'];

          if (url.length > 0 && id > 0) {
            const res = await fetch(url);

            if (res.ok) {
              const itemPageHTML = await res.text();
              const description = itemPageHTML.slice(itemPageHTML.indexOf('goods-descr-text'), itemPageHTML.indexOf('goods_reviews'));
              const found = description.match(regURL);

              if (found) {
                const type = found[3].slice(0, 3).toLowerCase();
                const steamID = parseInt(found[4], 10);
                const item = {};
                item[type] = steamID;

                plati.setItem(id, item);
                if (steam.isOwned(item)) classes.push('SBSE-item--owned');
                if (steam.isWished(item)) classes.push('SBSE-item--wished');
                if (steam.isIgnored(item)) classes.push('SBSE-item--ignored');
                if (classes.length === 1) classes.push('SBSE-item--notOwned');
                if (steam.isGame(item)) classes.push('SBSE-item--game');
                if (steam.isDLC(item)) classes.push('SBSE-item--DLC');
                if (steam.isPackage(item)) classes.push('SBSE-item--package');
              } else {
                plati.setItem(id, {});
                classes.push('SBSE-item--notApplicable');
              }
            } else classes.push('SBSE-item--failed');
          }

          $tr.removeClass('SBSE-item--owned SBSE-item--wished SBSE-item--ignored SBSE-item--notOwned SBSE-item--notApplicable');
          $tr.toggleClass(classes.join(' '));
          this.fetchItem(queue);
        } else plati.save();
      },
      fetchItems(items) {
        const filters = ['.SBSE-item--fetching'];
        if (plati.get('fetchOnStart')) filters.push('.SBSE-item--fetched');

        const $trs = items && items.length > 0 ? $(items) : $('.goods-table tbody > tr');
        const $filtered = $trs.filter(`.SBSE-item--steam:not(${filters.join()})`);

        $filtered.addClass('SBSE-item--fetching').removeClass('SBSE-item--notFetched');
        this.fetchItem($filtered.get());
      },
      process($rows = null) {
        if (plati.get('enablePlatiFeature')) {
          const $table = $('.goods-table');
          const $trs = $rows && $rows.length > 0 ? $rows : $table.find('tbody > tr');

          // setup type & icon node
          $trs.find('td:not(.icon) + .product-sold').before(`
            <td class="type"><span class="SBSE-type"></span></td>
            <td class="icon"><span class="SBSE-icon"></span></td>
          `);

          // setup price node
          $trs.filter(':not(:has(.SBSE-price))').find('.product-price div').each((i, price) => {
            const $price = $(price);
            const value = parseFloat($price.text().trim()) * 100;

            $price.replaceWith(`<span class="SBSE-price" data-currency="${platiCurrency}" data-value="${value}"></span>`);
          });

          // process
          $trs
            .filter(':not(.SBSE-item--processing, .SBSE-item--processed)')
            .addClass('SBSE-item--processing SBSE-item--steam')
            .each((i, tr) => {
              const $tr = $(tr);
              const url = $tr.find('.product-title a').attr('href');
              const id = parseInt(url.split('/').pop(), 10);

              if (url.length > 0 && id > 0) {
                const classes = [];
                const item = plati.getItem(id);

                if (item !== null) {
                  classes.push('SBSE-item--fetched');
                  if (item.app || item.sub) {
                    if (steam.isOwned(item)) classes.push('SBSE-item--owned');
                    if (steam.isWished(item)) classes.push('SBSE-item--wished');
                    if (steam.isIgnored(item)) classes.push('SBSE-item--ignored');
                    if (classes.length === 1) classes.push('SBSE-item--notOwned');
                    if (steam.isGame(item)) classes.push('SBSE-item--game');
                    if (steam.isDLC(item)) classes.push('SBSE-item--DLC');
                    if (steam.isPackage(item)) classes.push('SBSE-item--package');
                  } else classes.push('SBSE-item--notApplicable');

                  $tr.attr('data-item', JSON.stringify(item));
                }

                if (classes.length > 0) {
                  $tr
                    .removeClass('SBSE-item--owned SBSE-item--wished SBSE-item--ignored SBSE-item--notOwned SBSE-item--notApplicable')
                    .addClass(classes.join(' '));
                } else $tr.addClass('SBSE-item--notFetched');

                $tr.attr({
                  'data-id': id,
                  'data-url': location.origin + url,
                });
              }
            })
            .removeClass('SBSE-item--processing')
            .addClass('SBSE-item--processed');

          // auto fetch on page visit
          if (plati.get('fetchOnStart')) this.fetchItems();

          xe.update(selectedCurrency);
        }
      },
      initTable(table) {
        const $table = table ? $(table) : $('.goods-table');
        const filters = $('.SBSE-plati-menu [data-config^="filter"] input:not(:checked)').map((i, ele) => ele.dataset.filter).get();
        platiCurrency = $table.find('th.product-price select option:selected').text().trim();

        // apply filters
        $table.addClass(filters.join(' '));
        // add type & icon
        $table.find('thead th:not(.icon) + .product-sold').before('<th class="type"></th><th class="icon"></th>');

        // grab infinite scroll parameters
        infiniteScroll.setParameters();

        // bind infinite scroll event
        if (plati.get('infiniteScroll')) infiniteScroll.init();
      },
      init() {
        this.initTable();
        this.process();

        const self = this;

        // detect list changes
        new MutationObserver((mutations) => {
          mutations.forEach((mutation) => {
            Array.from(mutation.addedNodes).forEach((addedNode) => {
              const $addedNode = $(addedNode);

              if ($addedNode.is('.goods-table')) {
                self.initTable.call(self, $addedNode);
                self.process.call(self, $addedNode.find('tbody tr'));
              }
              if ($addedNode.is('tr')) {
                self.process.call(self, $addedNode);
              }
            });
          });
        }).observe($('body')[0], {
          childList: true,
          subtree: true,
        });
      },
    };
    const insertMenu = () => {
      const $menu = $(`
        <ul class="SBSE-plati-menu">
          <li data-config="enablePlatiFeature">
            <label class="SBSE-switch SBSE-switch--small">
              <input type="checkbox" id="enablePlatiFeature">
              <span class="SBSE-switch__slider"></span>
            </label>
            <label for="enablePlatiFeature"><span>${i18n.get('enablePlatiFeature')}</span></label>
          </li>
          <li data-config="fetchOnStart">
            <label class="SBSE-switch SBSE-switch--small">
              <input type="checkbox" id="fetchOnStart">
              <span class="SBSE-switch__slider"></span>
            </label>
            <label for="fetchOnStart"><span>${i18n.get('platiFetchOnStart')}</span></label>
          </li>
          <li data-config="infiniteScroll">
            <label class="SBSE-switch SBSE-switch--small">
              <input type="checkbox" id="infiniteScroll">
              <span class="SBSE-switch__slider"></span>
            </label>
            <label for="infiniteScroll"><span>${i18n.get('platiInfiniteScroll')}</span></label>
          </li>
          <li data-config="fetchButton"><span>${i18n.get('platiFetchButton')}</span></li>
          <li data-config="filterType" class="SBSE-dropdown">
            <span>${i18n.get('platiFilterType')}</span>
            <ul class="SBSE-dropdown__list">
              <li><label><input type="checkbox" data-filter="filterGame"><span>${i18n.get('game')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterDLC"><span>${i18n.get('dlc')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterPackage"><span>${i18n.get('package')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterBundle"><span>${i18n.get('bundle')}</span></label></li>
            </ul>
          </li>
          <li data-config="filterStatus" class="SBSE-dropdown">
            <span>${i18n.get('platiFilterStatus')}</span>
            <ul class="SBSE-dropdown__list">
              <li><label><input type="checkbox" data-filter="filterOwned"><span>${i18n.get('owned')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterWished"><span>${i18n.get('wished')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterIgnored"><span>${i18n.get('ignored')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterNotOwned"><span>${i18n.get('notOwned')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterNotApplicable"><span>${i18n.get('notApplicable')}</span></label></li>
              <li><label><input type="checkbox" data-filter="filterNotFetched"><span>${i18n.get('notFetched')}</span></label></li>
            </ul>
          </li>
          <li data-config="currency" class="SBSE-dropdown">
            <span class="selectedCurrency">${xe.currencies[selectedCurrency][config.get('language')]}</span>
            <ul class="SBSE-dropdown__list"></ul>
          </li>
          <li data-config="syncButton"><span>${i18n.get('settingsSyncLibrary')}</span></li>
        </ul>
      `);
      const $enablePlatiFeature = $menu.find('[data-config="enablePlatiFeature"] input');
      const $fetchOnStart = $menu.find('[data-config="fetchOnStart"] input');
      const $infiniteScroll = $menu.find('[data-config="infiniteScroll"] input');
      const $fetchButton = $menu.find('[data-config="fetchButton"] span');
      const $filters = $menu.find('[data-config^="filter"] input');
      const $currencyToggler = $menu.find('[data-config="currency"] ul');
      const $syncButton = $menu.find('[data-config="syncButton"] span');

      // bind event
      $enablePlatiFeature.on('change', () => {
        const state = $enablePlatiFeature.prop('checked');

        plati.set('enablePlatiFeature', state);
        $menu.find('li:not([data-config="enablePlatiFeature"])').toggleClass('hide1', !state);

        if (state) processor.init();
        $('body').toggleClass('enablePlatiFeature', state);
      });
      $fetchOnStart.on('change', () => {
        const state = $fetchOnStart.prop('checked');

        plati.set('fetchOnStart', state);
        $fetchButton.parent().toggleClass('hide2', state);
      });
      $infiniteScroll.on('change', () => {
        const state = $infiniteScroll.prop('checked');

        plati.set('infiniteScroll', state);
        infiniteScroll.enabled = state;
        $('body').toggleClass('infiniteScroll', state);

        // bind infinite scroll event if not already
        if (state) infiniteScroll.init();
      });
      $fetchButton.on('click', processor.fetchItems.bind(processor));
      $filters.on('change', (e) => {
        const input = e.delegateTarget;
        const filter = input.dataset.filter;
        const state = input.checked;

        plati.set(filter, state);
        $('.goods-table').toggleClass(filter, !state);
        infiniteScroll.scrollHandler();
      });
      Object.keys(xe.currencies).forEach((currency) => {
        const currencyName = xe.currencies[currency][config.get('language')];

        $currencyToggler.append(
          $(`<span>${currencyName}</span>`).on('click', () => {
            xe.update(currency);
            selectedCurrency = currency;
            $currencyToggler.prev('.selectedCurrency').text(currencyName);
          }),
        );
      });
      $currencyToggler.find('span').wrap('<li></li>');
      $syncButton.on('click', () => {
        steam.sync([{
          key: 'library',
          sync: true,
          save: true,
          notify: true,
          callback() {
            processor.process($('.goods-table tbody tr.SBSE-item--notOwned')).call(processor);
          },
        }]);
      });

      // apply config
      $enablePlatiFeature.prop('checked', plati.get('enablePlatiFeature'));
      $menu.find('li:not([data-config="enablePlatiFeature"])').toggleClass('hide1', !plati.get('enablePlatiFeature'));
      $fetchOnStart.prop('checked', plati.get('fetchOnStart'));
      $infiniteScroll.prop('checked', plati.get('infiniteScroll'));
      $fetchButton.parent().toggleClass('hide2', plati.get('fetchOnStart'));
      $filters.each((i, input) => {
        const filter = input.dataset.filter;
        const state = plati.get(filter);

        input.checked = state;
        $('.goods-table').toggleClass(filter, !state);
      });

      $('body')
        .toggleClass('enablePlatiFeature', plati.get('enablePlatiFeature'))
        .toggleClass('infiniteScroll', plati.get('infiniteScroll'));

      const $target = $('.merchant_products');

      if ($target.length === 0) $('.content_center').before($menu);
      else $target.eq(0).prepend($menu);
    };

    plati.init();

    // inject css styles
    GM_addStyle(`
      li[class*="hide"] { display: none; }
      .SBSE-plati-menu { display: flex; margin: 10px 0 0 0 !important; list-style: none; }
      .SBSE-plati-menu > li { height: 30px; line-height: 30px; padding-right: 30px; }
      .SBSE-plati-menu > li > .SBSE-switch { vertical-align: text-bottom; }
      .SBSE-plati-menu > li > * { cursor: pointer; }
      .SBSE-dropdown__list { width: max-content; z-index: 999; box-shadow: 5px 5px 10px grey; }
      .SBSE-dropdown__list li { cursor: default; }
      .SBSE-dropdown__list li > label, .SBSE-dropdown__list li > span { width: 100%; display: inline-block; margin: 0 10px; cursor: pointer; text-align: left; }
      tr.SBSE-item--processed:hover { background-color: #f3f3f3; }
      tr.SBSE-item--processed:hover .product-title > div::after { display: none; }
      .filterGame tr.SBSE-item--game,
      .filterDLC tr.SBSE-item--DLC,
      .filterPackage tr.SBSE-item--package,
      .filterBundle tr.SBSE_bundle,
      .filterOwned tr.SBSE-item--owned,
      .filterWished tr.SBSE-item--wished,
      .filterIgnored tr.SBSE-item--ignored,
      .filterNotOwned tr.SBSE-item--notOwned,
      .filterNotApplicable tr.SBSE-item--notApplicable,
      .filterNotFetched tr.SBSE-item--notFetched { display: none; }
      body.enablePlatiFeature .content_center { width: initial; }
      body.enablePlatiFeature .right_side { display: none; }
      body.enablePlatiFeature .goods-table { width: initial; }
      body.enablePlatiFeature .product-title > div { max-width: 600px !important; }
      body.enablePlatiFeature.infiniteScroll .SBSE-infiniteScroll-wrap {
        max-height: 600px;
        margin: 10px 0;
        overflow: auto;
      }
      body.enablePlatiFeature.infiniteScroll .goods-table { margin: 0; }
      body.enablePlatiFeature.infiniteScroll .goods-table tbody > tr > td:last-child { padding-right: 5px; }
      .SBSE-icon { vertical-align: middle; }
      body:not(.enablePlatiFeature) .type,
      body:not(.enablePlatiFeature) .icon { display: none; }
      .merchant_products > .SBSE-plati-menu { margin: 0 0 10px 0 !important; }
    `);

    if (location.pathname.startsWith('/seller/') || location.pathname.startsWith('/cat/')) {
      insertMenu();
      processor.init();
    }
  },
};
const init = () => {
  config.init();
  i18n.init();
  xe.init();
  steam.init();
  settings.init();
  SBSE.init();
  ASF.init();
  container.init();

  if (location.hostname === 'store.steampowered.com') {
    // save sessionID
    if (unsafeWindow.g_AccountID > 0) {
      const currentID = config.get('sessionID');
      const sessionID = unsafeWindow.g_sessionID || '';
      const language = unsafeWindow.g_oSuggestParams.l || 'english';

      if (!config.get('language')) config.set('language', language);
      if (sessionID.length > 0) {
        const update = config.get('autoUpdateSessionID') && currentID !== sessionID;

        if (!currentID || update) {
          config.set('sessionID', sessionID, () => {
            swal({
              title: i18n.get('updateSuccessTitle'),
              text: i18n.get('updateSuccess'),
              type: 'success',
              timer: 3000,
            });
          });
        }
      }
    }
  } else {
    const site = location.hostname.replace(/(www|alds|bundle|steamdb)\./, '').split('.').shift();

    // check sessionID
    if (!config.get('sessionID')) steam.getSessionID();

    if (has.call(siteHandlers, site)) siteHandlers[site](true);
  }

  keylolTooltip.listen();
};

$(init);