NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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);