Raw Source
MrMarble / HumbleBundle Key Exporter

// ==UserScript==
// @name        HumbleBundle Key Exporter
// @namespace   Violentmonkey Scripts
// @match       https://www.humblebundle.com/home/keys*
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2/dist/solid.min.js
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/ui@0.7
// @require     https://unpkg.com/lz-string@1.5.0/libs/lz-string.js
// @require     https://cdn.datatables.net/v/dt/dt-2.3.0/b-3.2.3/b-colvis-3.2.3/date-1.5.5/sb-1.8.2/datatables.min.js
// @resource    DATATABLES_CSS https://cdn.datatables.net/v/dt/dt-2.3.0/b-3.2.3/b-colvis-3.2.3/date-1.5.5/sb-1.8.2/datatables.min.css
// @version     0.3.0
// @author      MrMarble
// @license     MIT
// @description Userscript that aids in exporting Humble Bundle keys from the Humble Bundle website.
// @icon        https://www.google.com/s2/favicons?domain=humblebundle.com&sz=32
// @downloadURL https://github.com/MrMarble/hb-key-exporter/releases/latest/download/hb-key-exporter.user.js
// @homepageURL https://github.com/MrMarble/hb-key-exporter
// @grant       GM_addStyle
// @grant       GM_getResourceText
// @grant       GM_xmlhttpRequest
// ==/UserScript==

(function (web, solidJs, LZString, DataTable, dom, ui) {
'use strict';

const getCategory = category => {
  switch (category) {
    case 'storefront':
      return 'Store';
    case 'bundle':
      return 'Bundle';
    case 'subscriptioncontent':
      return 'Choice';
    default:
      return 'Other';
  }
};
const loadOrders = () => Object.keys(localStorage).filter(key => key.startsWith('v2|')).map(key => JSON.parse(LZString.decompressFromUTF16(localStorage.getItem(key)))).filter(order => {
  var _order$tpkd_dict;
  return order == null || (_order$tpkd_dict = order.tpkd_dict) == null || (_order$tpkd_dict = _order$tpkd_dict.all_tpks) == null ? void 0 : _order$tpkd_dict.length;
});
const getProducts = (orders, ownedApps) => orders.flatMap(order => order.tpkd_dict.all_tpks.map(product => ({
  machine_name: product.machine_name || '-',
  category: getCategory(order.product.category),
  category_id: order.gamekey,
  category_human_name: order.product.human_name || '-',
  human_name: product.human_name || product.machine_name || '-',
  key_type: product.key_type || '-',
  type: product.is_gift ? 'Gift' : product.redeemed_key_val ? 'Key' : '-',
  redeemed_key_val: product.redeemed_key_val || '',
  is_gift: product.is_gift || false,
  is_expired: product.is_expired || false,
  expiry_date: product.expiry_date || '',
  steam_app_id: product.steam_app_id,
  created: order.created || '',
  keyindex: product.keyindex,
  owned: product.steam_app_id ? ownedApps.includes(product.steam_app_id) ? 'Yes' : 'No' : '-'
})));
const redeem = async (product, gift = false) => {
  console.log('Redeeming product:', product.machine_name);
  const data = await fetch('https://www.humblebundle.com/humbler/redeemkey', {
    credentials: 'include',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
    },
    body: `keytype=${product.machine_name}&key=${product.category_id}&keyindex=${product.keyindex}${gift ? '&gift=true' : ''}`,
    method: 'POST',
    mode: 'cors'
  }).then(res => res.json());
  console.log('Redeem response:', data);
  return gift ? `https://www.humblebundle.com/gift?key=${data.giftkey}` : data.key;
};
const fetchOwnedApps = async () => new Promise(resolve => GM_xmlhttpRequest({
  url: 'https://store.steampowered.com/dynamicstore/userdata',
  method: 'GET',
  timeout: 5000,
  responseType: 'json',
  onload: resolve
})).then(data => {
  var _data$response, _data$response2;
  return ((data == null || (_data$response = data.response) == null ? void 0 : _data$response.rgOwnedPackages) || []).concat((data == null || (_data$response2 = data.response) == null ? void 0 : _data$response2.rgOwnedApps) || []);
}).catch(() => []);
let ownedApps = [];
const loadOwnedApps = async (refresh = false) => {
  if (!refresh && ownedApps.length) {
    console.debug('Using cached owned apps');
    return ownedApps;
  }
  console.debug('Fetching owned apps from Steam');
  // Try to load from localStorage first
  const storedApps = localStorage.getItem('hb-key-exporter-ownedApps');
  if (storedApps) {
    return JSON.parse(LZString.decompressFromUTF16(storedApps));
  }
  // If not found, fetch from Steam
  ownedApps = await fetchOwnedApps();
  if (!ownedApps) {
    return [];
  }
  // Store the result in localStorage for future use
  localStorage.setItem('hb-key-exporter-ownedApps', LZString.compressToUTF16(JSON.stringify(ownedApps)));
  return ownedApps;
};

var styles = {"platform":"style-module_platform__kpbfk","expired":"style-module_expired__IvOyt","select":"style-module_select__WN4g9","actions":"style-module_actions__aALan","btn":"style-module_btn__Bc4TY","row_actions":"style-module_row_actions__dEszH"};
var stylesheet="td.style-module_platform__kpbfk{font-size:20px;text-align:center}tr.style-module_expired__IvOyt{background-color:#f8d7da!important}.style-module_select__WN4g9{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f1f3f6 url(//cdn.humblebundle.com/static/hashed/ce14e404160fcb5d989503e532c4324f0297014d.gif) no-repeat 90% 50%;border:1px solid #ccc;padding:5px 30px 5px 10px;position:relative;top:-1px}.style-module_actions__aALan{align-items:center;display:flex;gap:10px;justify-content:end}.style-module_btn__Bc4TY{background-color:#e9e9ed;border:1px solid #8f8f9d;border-radius:5px;padding:1px 4px}.style-module_row_actions__dEszH{display:flex;gap:2px}";

var _tmpl$$3 = /*#__PURE__*/web.template(`<table id=hb_extractor-table class="display compact">`);
function Table({
  products,
  setDt
}) {
  let tableRef;
  solidJs.onMount(() => {
    console.debug('Mounting table with', products.length, 'products');
    setDt(() => new DataTable(tableRef, {
      columnDefs: [{
        targets: [7, 8],
        render: DataTable.render.date()
      }, {
        targets: [9],
        data: null,
        defaultContent: ''
      }],
      order: {
        idx: 7,
        dir: 'desc'
      },
      columns: [{
        title: 'Type',
        data: 'key_type',
        type: 'html-utf8',
        render: (data, type, row) => dom.hm('i', {
          class: `hb hb-key hb-${data}`,
          onclick: () => ui.showToast(JSON.stringify(row, null, 2))
        }, dom.hm('span', {
          class: 'hidden',
          innerText: data
        })),
        className: styles.platform
      }, {
        title: 'Name',
        data: 'human_name',
        type: 'html-utf8',
        render: (data, _, row) => row.steam_app_id ? dom.hm('a', {
          href: `https://store.steampowered.com/app/${row.steam_app_id}`,
          target: '_blank',
          innerText: data
        }) : data
      }, {
        title: 'Category',
        data: 'category',
        type: 'string-utf8'
      }, {
        title: 'Bundle Name',
        data: 'category_human_name',
        type: 'html-utf8',
        render: (data, _, row) => dom.hm('a', {
          href: `https://www.humblebundle.com/download?key=${row.category_id}`,
          target: '_blank',
          innerText: data
        })
      }, {
        title: 'Gift',
        data: 'type',
        type: 'string-utf8'
      }, {
        title: 'Revealed',
        data: row => row.is_gift || row.redeemed_key_val ? 'Yes' : 'No',
        type: 'string-utf8'
      }, {
        title: 'Owned',
        data: 'owned',
        type: 'string-utf8'
      }, {
        title: 'Purchased',
        data: 'created',
        type: 'date'
      }, {
        title: 'Exp. Date',
        data: 'expiry_date',
        type: 'date'
      }, {
        title: '',
        orderable: false,
        searchable: false,
        data: row => {
          const actions = [];
          if (row.redeemed_key_val) {
            actions.push(dom.hm('button', {
              class: styles.btn,
              title: 'Copy to clipboard',
              type: 'button',
              onclick: () => {
                navigator.clipboard.writeText(row.redeemed_key_val);
                ui.showToast('Copied to clipboard');
              }
            }, dom.hm('i', {
              class: 'hb hb-key hb-clipboard'
            })));
          }
          if (row.redeemed_key_val && !row.is_gift && !row.is_expired && row.key_type === 'steam') {
            actions.push(dom.hm('a', {
              class: styles.btn,
              href: `https://store.steampowered.com/account/registerkey?key=${row.redeemed_key_val}`,
              target: '_blank'
            }, dom.hm('i', {
              class: 'hb hb-shopping-cart-light',
              title: 'Redeem'
            })));
          }
          if (row.redeemed_key_val && row.is_gift && !row.is_expired) {
            actions.push(dom.hm('a', {
              class: styles.btn,
              href: row.redeemed_key_val,
              target: '_blank'
            }, dom.hm('i', {
              class: 'hb hb-shopping-cart-light',
              title: 'Redeem'
            })));
          }
          if (!row.redeemed_key_val && !row.is_gift && !row.is_expired) {
            actions.push(dom.hm('button', {
              class: styles.btn,
              type: 'button',
              onclick: () => {
                redeem(row).then(data => navigator.clipboard.writeText(data)).then(() => ui.showToast('Key copied to clipboard'));
              }
            }, dom.hm('i', {
              class: 'hb hb-magic',
              title: 'Reveal'
            })), dom.hm('button', {
              class: styles.btn,
              type: 'button',
              onclick: () => {
                redeem(row, true).then(link => navigator.clipboard.writeText(link)).then(() => ui.showToast('Link copied to clipboard'));
              }
            }, dom.hm('i', {
              class: 'hb hb-gift',
              title: 'Create gift link'
            })));
          }
          return dom.hm('div', {
            class: styles.row_actions
          }, actions);
        }
      }],
      data: products,
      layout: {
        top1: 'searchBuilder'
      },
      createdRow: function (row, data) {
        if (data.is_expired) {
          row.classList.add(styles.expired);
        }
      }
    }));
  });
  console.debug('Table Loaded');
  return (() => {
    var _el$ = _tmpl$$3();
    var _ref$ = tableRef;
    typeof _ref$ === "function" ? web.use(_ref$, _el$) : tableRef = _el$;
    return _el$;
  })();
}

var _tmpl$$2 = /*#__PURE__*/web.template(`<button type=button title="Reload products"><i class="hb hb-refresh">`);
function Refresh({
  refresh
}) {
  return (() => {
    var _el$ = _tmpl$$2();
    _el$.$$click = () => refresh();
    return _el$;
  })();
}
web.delegateEvents(["click"]);

var _tmpl$$1 = /*#__PURE__*/web.template(`<div><label for=claim><input type=checkbox id=claim name=claim>Claim unredeemed games</label><select name=claimType id=claimType><option value disabled>What to claim</option><option value=key selected>Key</option><option value=gift>Gift link</option></select><label for=filtered><input type=checkbox id=filtered name=filtered>Use table filter</label><select name=export id=export><option value disabled selected>Export format</option><option value=asf>ASF</option><option value=keys>Keys</option><option value=csv>CSV</option></select><button type=button class=primary-button>`),
  _tmpl$2$1 = /*#__PURE__*/web.template(`<i class="hb hb-spin hb-spinner">`);
function Actions({
  dt
}) {
  const [exportType, setExportType] = solidJs.createSignal('');
  const [filtered, setFiltered] = solidJs.createSignal(false);
  const [claim, setClaim] = solidJs.createSignal(false);
  const [claimType, setClaimType] = solidJs.createSignal('key');
  const [exporting, setExporting] = solidJs.createSignal(false);
  const exportASF = products => {
    const keys = products.filter(product => !product.is_gift && product.redeemed_key_val && !product.is_expired && product.key_type === 'steam').map(product => `${product.redeemed_key_val}\t${product.human_name}`).join('\n');
    navigator.clipboard.writeText(keys);
  };
  const exportKeys = products => {
    const keys = products.filter(product => !product.is_gift && product.redeemed_key_val).map(product => product.redeemed_key_val).join('\n');
    navigator.clipboard.writeText(keys);
  };
  const exportCSV = products => {
    const header = Object.keys(products[0]);
    const csv = products.map(product => {
      return header.map(h => product[h]).join(',');
    }).join('\n');
    navigator.clipboard.writeText(header + '\n' + csv);
  };
  const exportToClipboard = async () => {
    setExporting(true);
    const toExport = dt().rows({
      search: filtered() ? 'applied' : 'none'
    }).data().toArray();
    if (claim()) {
      for (const product of toExport) {
        if (product.redeemed_key_val) {
          continue;
        }
        try {
          product.redeemed_key_val = await redeem(product, claimType() === 'gift');
        } catch (e) {
          console.error('Error redeeming product:', product.machine_name, e);
        }
      }
    }
    switch (exportType()) {
      case 'asf':
        exportASF(toExport);
        break;
      case 'keys':
        exportKeys(toExport);
        break;
      case 'csv':
        exportCSV(toExport);
        break;
    }
    setExporting(false);
    ui.showToast('Exported to clipboard');
  };
  return (() => {
    var _el$ = _tmpl$$1(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.firstChild,
      _el$4 = _el$2.nextSibling,
      _el$5 = _el$4.nextSibling,
      _el$6 = _el$5.firstChild,
      _el$7 = _el$5.nextSibling,
      _el$8 = _el$7.nextSibling;
    _el$3.addEventListener("change", e => setClaim(e.target.checked));
    _el$4.addEventListener("change", e => setClaimType(e.target.value));
    _el$6.addEventListener("change", e => setFiltered(e.target.checked));
    _el$7.addEventListener("change", e => setExportType(e.target.value));
    _el$8.$$click = exportToClipboard;
    web.insert(_el$8, (() => {
      var _c$ = web.memo(() => !!exporting());
      return () => _c$() ? _tmpl$2$1() : 'Export';
    })());
    web.effect(_p$ => {
      var _v$ = styles.actions,
        _v$2 = styles.select,
        _v$3 = !claim(),
        _v$4 = styles.select,
        _v$5 = !exportType() || exporting();
      _v$ !== _p$.e && web.className(_el$, _p$.e = _v$);
      _v$2 !== _p$.t && web.className(_el$4, _p$.t = _v$2);
      _v$3 !== _p$.a && _el$4.classList.toggle("hidden", _p$.a = _v$3);
      _v$4 !== _p$.o && web.className(_el$7, _p$.o = _v$4);
      _v$5 !== _p$.i && (_el$8.disabled = _p$.i = _v$5);
      return _p$;
    }, {
      e: undefined,
      t: undefined,
      a: undefined,
      o: undefined,
      i: undefined
    });
    return _el$;
  })();
}
web.delegateEvents(["click"]);

var _tmpl$ = /*#__PURE__*/web.template(`<details><summary><h3><i class="hb hb-key"></i> Advanced Exporter</h3></summary><div>`),
  _tmpl$2 = /*#__PURE__*/web.template(`<p>Loading products...`);
function App() {
  const [products, {
    refetch: refresh
  }] = solidJs.createResource(async (_, info) => {
    console.debug('Loading products...');
    const orders = loadOrders();
    const owned = await loadOwnedApps(info.refetching);
    console.debug('Loaded', orders.length, 'orders,', owned.length, 'owned apps');
    return getProducts(orders, owned);
  });
  const [dt, setDt] = solidJs.createSignal(null);
  console.debug('App loaded');
  return (() => {
    var _el$ = _tmpl$(),
      _el$2 = _el$.firstChild,
      _el$3 = _el$2.nextSibling;
    _el$3.style.setProperty("display", "flex");
    _el$3.style.setProperty("justify-content", "end");
    _el$3.style.setProperty("align-items", "center");
    web.insert(_el$3, web.createComponent(Refresh, {
      refresh: refresh
    }));
    web.insert(_el$, web.createComponent(solidJs.Show, {
      get when() {
        var _products;
        return (_products = products()) == null ? void 0 : _products.length;
      },
      get fallback() {
        return _tmpl$2();
      },
      get children() {
        return web.createComponent(Table, {
          get products() {
            return products();
          },
          setDt: setDt
        });
      }
    }), null);
    web.insert(_el$, web.createComponent(Actions, {
      dt: dt
    }), null);
    return _el$;
  })();
}

var css_248z = ".base-main-wrapper,.inner-main-wrapper{width:1020px!important}#hb_extractor-container{margin-bottom:15px}#hb_extractor-container details{display:unset;width:100%}#hb_extractor-container .dtsb-titleRow{display:none}";

// Create a container for the script
document.querySelector('.inner-main-wrapper').insertAdjacentHTML('afterbegin', '<div id="hb_extractor-container"></div>');

// Render the app
web.render(App, document.getElementById('hb_extractor-container'));

// Add CSS styles
GM_addStyle(GM_getResourceText('DATATABLES_CSS'));
GM_addStyle(stylesheet);
GM_addStyle(css_248z);

})(VM.solid.web, VM.solid, LZString, DataTable, VM, VM);