Bungie Code Redeemer

Automatically redeem multiple shop codes one by one

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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;
    }

})();