Automatically redeem multiple shop codes one by one
// ==UserScript==
// @name Bungie Code Redeemer
// @namespace https://github.com/LasmGratel/
// @version 1.1
// @description Automatically redeem multiple shop codes one by one
// @author Lasm Gratel
// @match https://www.bungie.net/7/en/codes/redeem
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ─── CONFIG ────────────────────────────────────────────────────────────────
// Delay (ms) between each step – increase if the site is slow
const STEP_DELAY = 500; // after typing / before clicking
const BETWEEN_DELAY = 1200; // after "Redeem Another Code", before next code
// ───────────────────────────────────────────────────────────────────────────
/* ── Helper: trigger React's synthetic onChange on an <input> ── */
function setReactInputValue(input, value) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeSetter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
/* ── Helper: wait until a DOM element matching selector appears ── */
function waitForElement(selector, root = document.body, timeout = 12000) {
return new Promise((resolve, reject) => {
const found = root.querySelector(selector);
if (found) return resolve(found);
const obs = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) { obs.disconnect(); resolve(el); }
});
obs.observe(root, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); reject(new Error('Timeout: ' + selector)); }, timeout);
});
}
/* ── Helper: wait until a <button> whose trimmed text starts with `text` ── */
function waitForButton(text, timeout = 12000) {
function find() {
return Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim().startsWith(text));
}
return new Promise((resolve, reject) => {
const el = find();
if (el) return resolve(el);
const obs = new MutationObserver(() => {
const el = find();
if (el) { obs.disconnect(); resolve(el); }
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); reject(new Error('Timeout waiting for button: ' + text)); }, timeout);
});
}
/**
* After clicking "Redeem Code", race between:
* { type: 'success' } – "Redeem Another Code" button appeared
* { type: 'error', message, okayBtn } – error modal appeared
*/
function waitForSuccessOrError(timeout = 15000) {
return new Promise((resolve, reject) => {
function check() {
// Success path
const another = Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim().startsWith('Redeem Another Code'));
if (another) return { type: 'success', btn: another };
// Error modal path – button with labeloverride="OKAY"
const okay = document.querySelector('button[labeloverride="OKAY"]');
if (okay) {
// Grab the human-readable error description if available
const descEl = document.querySelector('[class*="ConfirmationModal_description"] [class*="errorContent"], [class*="ConfirmationModal_description"]');
const message = descEl ? descEl.textContent.trim() : 'Unknown error';
return { type: 'error', message, okayBtn: okay };
}
return null;
}
const immediate = check();
if (immediate) return resolve(immediate);
const obs = new MutationObserver(() => {
const result = check();
if (result) { obs.disconnect(); resolve(result); }
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); reject(new Error('Timeout waiting for redemption result')); }, timeout);
});
}
/**
* Normalize a raw string to XXX-XXX-XXX format.
* Strips all non-alphanumeric chars, validates exactly 9 chars remain.
* Returns the formatted code or null if invalid.
*/
function normalizeCode(raw) {
const stripped = raw.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
if (!/^[A-Z0-9]{9}$/.test(stripped)) return null;
return `${stripped.slice(0, 3)}-${stripped.slice(3, 6)}-${stripped.slice(6, 9)}`;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* ══════════════════════════════════════════════════════════════════
BUILD THE FLOATING PANEL
══════════════════════════════════════════════════════════════════ */
const panel = Object.assign(document.createElement('div'), {
id: 'cr-panel',
});
Object.assign(panel.style, {
position: 'fixed',
top: '20px',
right: '20px',
background: '#16213e',
color: '#eee',
padding: '14px 16px',
borderRadius: '10px',
zIndex: '2147483647',
fontFamily: 'monospace, sans-serif',
fontSize: '13px',
minWidth: '280px',
boxShadow: '0 4px 20px rgba(0,0,0,0.6)',
userSelect: 'none',
});
panel.innerHTML = `
<div style="font-size:15px;font-weight:bold;margin-bottom:10px;color:#e94560;">
🎁 Code Redeemer
</div>
<textarea id="cr-codes" rows="6" placeholder="Paste one code per line…"
style="width:100%;box-sizing:border-box;background:#0f3460;color:#eee;
border:1px solid #e94560;border-radius:6px;padding:6px;
font-family:monospace;font-size:13px;resize:vertical;"></textarea>
<div id="cr-status" style="margin:8px 0;color:#a8dadc;min-height:18px;">Ready – paste codes above</div>
<div id="cr-progress" style="font-size:12px;color:#aaa;margin-bottom:8px;"></div>
<div id="cr-log-wrap" style="display:none;margin-bottom:8px;">
<div style="font-size:12px;color:#e94560;margin-bottom:4px;">⚠ Error log:</div>
<div id="cr-log"
style="max-height:90px;overflow-y:auto;background:#0a0a1a;border:1px solid #333;
border-radius:4px;padding:4px 6px;font-size:11px;color:#f0a500;
word-break:break-all;"></div>
</div>
<div style="display:flex;gap:8px;">
<button id="cr-start"
style="flex:1;background:#e94560;color:#fff;border:none;padding:7px 10px;
border-radius:6px;cursor:pointer;font-size:13px;">
▶ Start
</button>
<button id="cr-close"
style="background:#333;color:#ccc;border:none;padding:7px 10px;
border-radius:6px;cursor:pointer;font-size:13px;">
✕
</button>
</div>
`;
document.body.appendChild(panel);
/* Make panel draggable */
let dragging = false, ox = 0, oy = 0;
panel.addEventListener('mousedown', e => {
if (['TEXTAREA','BUTTON'].includes(e.target.tagName)) return;
dragging = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop;
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
panel.style.left = (e.clientX - ox) + 'px';
panel.style.top = (e.clientY - oy) + 'px';
panel.style.right = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
document.getElementById('cr-close').onclick = () => panel.remove();
/* ══════════════════════════════════════════════════════════════════
MAIN REDEMPTION LOOP
══════════════════════════════════════════════════════════════════ */
document.getElementById('cr-start').onclick = async function () {
const textarea = document.getElementById('cr-codes');
const codes = textarea.value
.split('\n')
.map(s => s.trim())
.filter(Boolean);
if (!codes.length) {
setStatus('⚠ No codes entered!');
return;
}
this.disabled = true;
this.style.opacity = '0.5';
textarea.disabled = true;
// ── Normalize & validate all codes up-front ──────────────────────────
const validated = [];
for (const raw of codes) {
const normalized = normalizeCode(raw);
if (normalized) {
validated.push(normalized);
} else {
logError(`INVALID (skipped): "${raw}" — must contain exactly 9 alphanumeric chars`);
}
}
if (!validated.length) {
setStatus('⚠ No valid codes found!');
this.disabled = false;
this.style.opacity = '1';
textarea.disabled = false;
return;
}
const total = validated.length;
for (let i = 0; i < total; i++) {
const code = validated[i];
setProgress(i, total, code);
try {
/* 1. Wait for the input box to be present */
setStatus('⏳ Waiting for input field…');
const inputBox = await waitForElement('[class*="CodesRedemptionForm_inputBox"]');
const input = inputBox.querySelector('input') || inputBox;
await sleep(STEP_DELAY);
/* 2. Type the code (React-safe) */
input.focus();
setReactInputValue(input, code);
setStatus(`✏ Entering code: ${code}`);
await sleep(STEP_DELAY);
/* 3. Click "Redeem Code" */
const redeemBtn = await waitForButton('Redeem Code');
redeemBtn.click();
setStatus(`🚀 Redeeming: ${code}…`);
/* 4. Race: success modal OR error modal */
setStatus('⏳ Waiting for result…');
const result = await waitForSuccessOrError();
if (result.type === 'error') {
// ── Error path ──────────────────────────────────────────
logError(`${code}: ${result.message}`);
setStatus(`⚠ Error on ${code} — see log`);
// Dismiss the modal
result.okayBtn.click();
await sleep(STEP_DELAY);
// Clear the input so the form is clean for the next code
const inputBox2 = document.querySelector('[class*="CodesRedemptionForm_inputBox"]');
if (inputBox2) {
const inp2 = inputBox2.querySelector('input') || inputBox2;
setReactInputValue(inp2, '');
}
await sleep(BETWEEN_DELAY);
} else {
// ── Success path ─────────────────────────────────────────
setStatus(`✅ Code ${i + 1}/${total} redeemed: ${code}`);
await sleep(STEP_DELAY);
/* 5. Click "Redeem Another Code" to go back */
result.btn.click();
await sleep(BETWEEN_DELAY);
}
} catch (err) {
setStatus(`❌ Error on "${code}": ${err.message}`);
logError(`${code}: ${err.message}`);
await sleep(3000);
}
}
setProgress(total, total, '');
setStatus('🎉 All codes processed!');
this.disabled = false;
this.style.opacity = '1';
textarea.disabled = false;
};
function setStatus(msg) {
const el = document.getElementById('cr-status');
if (el) el.textContent = msg;
}
function setProgress(done, total, current) {
const el = document.getElementById('cr-progress');
if (!el) return;
el.textContent = total
? `Progress: ${done}/${total}${current ? ' | Current: ' + current : ''}`
: '';
}
function logError(msg) {
const wrap = document.getElementById('cr-log-wrap');
const log = document.getElementById('cr-log');
if (!wrap || !log) return;
wrap.style.display = 'block';
const line = document.createElement('div');
line.style.borderBottom = '1px solid #222';
line.style.paddingBottom = '2px';
line.style.marginBottom = '2px';
const ts = new Date().toLocaleTimeString();
line.textContent = `[${ts}] ${msg}`;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
})();