Real-Debrid Premium Link Converter

Convert links using Real-Debrid. Uses /hosts/regex for accurate matching. Collapsed toolbox, selection/context conversion, improved results panel with textarea for successful links.

// ==UserScript==
// @name         Real-Debrid Premium Link Converter
// @version      5.4.5
// @grant        GM.xmlHttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @namespace    https://gf.qytechs.cn/en/users/807108-jeremy-r
// @include      *://*
// @exclude      https://real-debrid.com/*
// @description  Convert links using Real-Debrid. Uses /hosts/regex for accurate matching. Collapsed toolbox, selection/context conversion, improved results panel with textarea for successful links.
// @icon         https://icons.duckduckgo.com/ip2/real-debrid.com.ico
// @run-at       document-end
// @author       JRem
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    // ---- storage/state ----
    let targetRegexStrings = GM_getValue('targetRegexStrings', []) || [];
    let token = GM_getValue('api_token', '') || '';
    const processedURLs = new Set();

    // compiled regex objects: [{ pattern: string, global: RegExp, test: RegExp }]
    let compiledRegexes = [];

    // results UI state: map url -> entry element
    const resultsEntries = new Map();
    const successfulDownloads = []; // list of download URLs added to textarea

    // ---- helpers ----
    function showToast(message, ms = 3000) {
        const toast = document.createElement('div');
        toast.textContent = message;
        Object.assign(toast.style, {
            position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)',
            backgroundColor: '#333', color: '#fff', padding: '8px 14px', borderRadius: '6px',
            zIndex: '9999999', fontSize: '13px'
        });
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), ms);
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            options.onload = options.onload || (r => resolve(r));
            options.onerror = options.onerror || (e => reject(e));
            try {
                GM.xmlHttpRequest(options);
            } catch (e) {
                reject(e);
            }
        });
    }

    // ---- regex compile/load ----
    function compileServerRegexString(s) {
        if (!s || typeof s !== 'string') return null;
        let pattern = s;
        let flags = '';
        if (pattern.startsWith('/')) {
            const lastSlash = pattern.lastIndexOf('/');
            if (lastSlash > 0) {
                flags = pattern.slice(lastSlash + 1);
                pattern = pattern.slice(1, lastSlash);
            } else {
                pattern = pattern.slice(1);
            }
        }
        try {
            const global = new RegExp(pattern, 'ig'); // scanning
            const test = new RegExp(pattern, 'i');    // single test
            return { pattern, global, test, original: s };
        } catch (e) {
            console.warn('Failed to compile RD regex:', s, e);
            return null;
        }
    }

    function compileAllRegexes() {
        compiledRegexes = [];
        if (!Array.isArray(targetRegexStrings)) return;
        for (const s of targetRegexStrings) {
            const comp = compileServerRegexString(s);
            if (comp) compiledRegexes.push(comp);
        }
    }

    function isRegexListLoaded() { return compiledRegexes.length > 0; }

    function urlDomain(url) {
        try {
            const u = new URL(url);
            return u.hostname.replace(/^www\./i, '').toLowerCase();
        } catch (e) {
            const m = url.match(/https?:\/\/([^\/]+)/i);
            return m ? m[1].replace(/^www\./i, '').toLowerCase() : '';
        }
    }

    function extractUrlsUsingRegexesFromText(text) {
        if (!text || !isRegexListLoaded()) return [];
        const set = new Set();
        for (const comp of compiledRegexes) {
            try {
                comp.global.lastIndex = 0;
                let m;
                while ((m = comp.global.exec(text)) !== null) {
                    const candidate = m[0].trim();
                    if (candidate) set.add(candidate);
                }
            } catch (e) {
                console.warn('regex scan failed for pattern', comp.pattern, e);
            }
        }
        return Array.from(set);
    }

    function urlMatchesAnyRegex(href) {
        if (!href || !isRegexListLoaded()) return false;
        for (const comp of compiledRegexes) {
            try {
                if (comp.test.test(href)) return true;
            } catch (e) {}
        }
        return false;
    }

    // ---- RD API call ----
    async function rdUnrestrict(link) {
        if (!token) return { success: false, error: 'No API token' };
        try {
            const response = await gmRequest({
                method: 'POST',
                url: 'https://app.real-debrid.com/rest/1.0/unrestrict/link',
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Content-Type': 'application/x-www-form-urlencoded'
                },
                data: `link=${encodeURIComponent(link)}&password=`
            });
            if (!response || !response.responseText) {
                return { success: false, error: `Empty response (status ${response ? response.status : 'n/a'})` };
            }
            if (response.status >= 200 && response.status < 300) {
                const json = JSON.parse(response.responseText);
                if (json && json.download) return { success: true, download: json.download, filename: json.filename, raw: json };
                return { success: false, error: JSON.stringify(json) };
            } else {
                let parsed = response.responseText;
                try { parsed = JSON.parse(response.responseText); } catch (e) {}
                return { success: false, error: `Status ${response.status}: ${JSON.stringify(parsed)}`, errmsg: `${parsed.error}` };
            }
        } catch (e) {
            return { success: false, error: e && e.message ? e.message : String(e) };
        }
    }

    // ---- find matched hosts on page ----
    function findMatchingHostsOnPage() {
        const map = new Map();
        // anchors
        document.querySelectorAll('a[href]').forEach(a => {
            try {
                const href = a.href;
                if (!href || !(href.startsWith('http://') || href.startsWith('https://'))) return;
                if (!urlMatchesAnyRegex(href)) return;
                const host = urlDomain(href);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(href);
                map.get(host).anchors.add(a);
                map.get(host).sources.add('link');
            } catch (e) {}
        });
        // textareas
        document.querySelectorAll('textarea').forEach(t => {
            const urls = extractUrlsUsingRegexesFromText(t.value || '');
            urls.forEach(u => {
                const host = urlDomain(u);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(u);
                map.get(host).sources.add('textarea');
            });
        });
        // pre/code
        document.querySelectorAll('pre, code').forEach(el => {
            const txt = el.innerText || el.textContent || '';
            const urls = extractUrlsUsingRegexesFromText(txt);
            urls.forEach(u => {
                const host = urlDomain(u);
                if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
                map.get(host).urls.add(u);
                map.get(host).sources.add('pre');
            });
        });
        // body text fallback
        const bodyText = document.body ? (document.body.innerText || '') : '';
        extractUrlsUsingRegexesFromText(bodyText).forEach(u => {
            const host = urlDomain(u);
            if (!map.has(host)) map.set(host, { urls: new Set(), anchors: new Set(), sources: new Set() });
            map.get(host).urls.add(u);
            map.get(host).sources.add('text');
        });
        const result = {};
        for (const [host, data] of map.entries()) {
            result[host] = { urls: Array.from(data.urls), anchors: Array.from(data.anchors), sources: Array.from(data.sources) };
        }
        return result;
    }

    // ---- Results panel UI (top list + successful textarea) ----
    let toolboxWrapper = null;
    let toolboxContent = null;
    let resultsPanel = null;
    let convertSelectionBtn = null;

    function createResultsPanel() {
        if (resultsPanel) return resultsPanel;

        resultsPanel = document.createElement('div');
        Object.assign(resultsPanel.style, {
            position: 'fixed',
            right: '10px',
            bottom: '10px',
            zIndex: '9999999',
            background: 'rgba(111,111,111,0.98)',
            color: '#000',
            fontSize: '14px',
            padding: '10px',
            borderRadius: '8px',
            width: '560px',
            maxHeight: '80vh',       // limit panel height relative to viewport
            overflow: 'auto',        // allow scrolling of the panel when content exceeds maxHeight
            boxShadow: '0 8px 30px rgba(0,0,0,0.25)'
        });

        // Header
        const header = document.createElement('div');
        Object.assign(header.style, { display: 'fixed', justifyContent: 'space-between', alignItems: 'center' });
        const title = document.createElement('div'); title.textContent = 'RD Conversion Results'; title.style.fontWeight = '700';
        const controls = document.createElement('div');
        const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close'; closeBtn.onclick = () => resultsPanel.style.display = 'none';
        controls.appendChild(closeBtn);
        header.appendChild(title); header.appendChild(controls); resultsPanel.appendChild(header);
        

        // Top pane: list of URLs and statuses
        const topPane = document.createElement('div');
        topPane.id = 'rd-results-top';
        Object.assign(topPane.style, {
            display: 'fixed',
            marginTop: '4px',
            background: '#111',
            color: '#fff',
            fontSize: '14px',
            padding: '4px',
            borderRadius: '6px',
            maxHeight: '30vh',   // limit top pane height so bottom pane stays visible
            overflow: 'auto'     // internal scrollbar for the list
        });
        // instruction
        const topInfo = document.createElement('div');
        topInfo.textContent = 'Links and statuses (updated in place):';
        topInfo.style.marginBottom = '6px';
        topPane.appendChild(topInfo);
        const list = document.createElement('div'); list.id = 'rd-results-list';
        topPane.appendChild(list);
        resultsPanel.appendChild(topPane);

        // Bottom pane: textarea for successful downloads and buttons
        const bottomPane = document.createElement('div');
        bottomPane.id = 'rd-results-bottom';
        Object.assign(bottomPane.style, { marginTop: '10px' });

        const bottomInfo = document.createElement('div');
        bottomInfo.textContent = 'Successful download links (copyable):';
        bottomInfo.style.marginBottom = '6px';
        bottomPane.appendChild(bottomInfo);

        const textarea = document.createElement('textarea');
        textarea.id = 'rd-success-textarea';
        Object.assign(textarea.style, {
            display: 'fixed',
            width: '100%',
            height: '160px',       // fixed height for textarea
            maxHeight: '30vh',     // prevent bottom pane from growing too large
            boxSizing: 'border-box',
            padding: '8px',
            fontSize: '12px',
            overflow: 'auto'
        });
        textarea.readOnly = false;
        bottomPane.appendChild(textarea);

        const btnRow = document.createElement('div');
        Object.assign(btnRow.style, { display: 'flex', gap: '8px', marginTop: '6px' });
        const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy All'; copyBtn.onclick = () => {
            textarea.select();
            document.execCommand('copy');
            showToast('Copied to clipboard');
        };
        const clearBtn = document.createElement('button'); clearBtn.textContent = 'Clear'; clearBtn.onclick = () => {
            textarea.value = '';
            successfulDownloads.length = 0;
            showToast('Cleared successful links');
        };
        btnRow.appendChild(copyBtn); btnRow.appendChild(clearBtn);
        bottomPane.appendChild(btnRow);
        resultsPanel.appendChild(bottomPane);

        resultsPanel.style.display = 'none';
        document.body.appendChild(resultsPanel);
        return resultsPanel;
    }

    function ensureResultEntry(url) {
        createResultsPanel();
        if (resultsEntries.has(url)) return resultsEntries.get(url);
        const list = document.getElementById('rd-results-list');

        const row = document.createElement('div');
        row.style.display = 'flex';
        row.style.alignItems = 'center';
        row.style.justifyContent = 'space-between';
        row.style.padding = '6px';
        row.style.borderBottom = '1px solid rgba(255,255,255,0.06)';

        const left = document.createElement('div');
        left.style.display = 'flex';
        left.style.alignItems = 'center';
        left.style.gap = '8px';
        const a = document.createElement('a');
        a.href = url;
        a.textContent = url.length > 80 ? url.slice(0, 77) + '…' : url;
        a.title = url;
        a.target = '_blank';
        a.style.color = '#fff';
        a.style.textDecoration = 'underline';
        a.style.wordBreak = 'break-all';
        left.appendChild(a);
        row.appendChild(left);

        const status = document.createElement('div');
        status.className = 'rd-status';
        status.textContent = 'pending';
        status.style.color = '#ffffff';
        status.style.fontWeight = '700';
        status.style.marginLeft = '8px';
        row.appendChild(status);

        // Attach to list and map
        list.insertBefore(row, list.firstChild);
        resultsEntries.set(url, { row, linkEl: a, statusEl: status });
        return resultsEntries.get(url);
    }

    function updateResultStatus(url, state, extraText) {
        // state: 'pending' | 'ok' | 'fail' | 'skip'
        const entry = ensureResultEntry(url);
        const statusEl = entry.statusEl;
        if (!statusEl) return;
        if (state === 'pending') {
            statusEl.textContent = extraText || 'pending';
            statusEl.style.color = '#ffffff';
        } else if (state === 'ok') {
            statusEl.textContent = extraText || 'OK';
            statusEl.style.color = 'green';
        } else if (state === 'fail') {
            statusEl.textContent = extraText || 'FAILED';
            statusEl.style.color = 'red';
        } else if (state === 'skip') {
            statusEl.textContent = extraText || 'SKIPPED';
            statusEl.style.color = 'gray';
        }
    }

    function appendSuccessfulDownload(downloadUrl) {
        const ta = document.getElementById('rd-success-textarea');
        if (!ta) {
            // ensure panel created
            createResultsPanel();
        }
        const downloadToAdd = downloadUrl.trim();
        if (!downloadToAdd) return;
        // avoid duplicates
        if (successfulDownloads.includes(downloadToAdd)) return;
        successfulDownloads.push(downloadToAdd);
        const textarea = document.getElementById('rd-success-textarea');
        if (textarea) {
            textarea.value = successfulDownloads.join('\n');
        }
    }

    // ---- toolbox UI and domain buttons (regex-based) ----
    function createToolbox() {
        if (toolboxWrapper) return toolboxWrapper;

        toolboxWrapper = document.createElement('div');
        Object.assign(toolboxWrapper.style, {
            position: 'fixed',
            zIndex: '999999',
            fontFamily: 'Arial, sans-serif',
            userSelect: 'none',
            touchAction: 'none'
        });

        // ---- saved-percent helpers (same as before) ----
        function getSavedPositionPct() {
            try { return GM_getValue('rd_button_pos_pct', null); } catch (e) { return null; }
        }
        function savePositionPctFromPx(leftPx, topPx) {
            try {
                const leftPct = Math.round((leftPx / window.innerWidth) * 10000) / 10000;
                const topPct = Math.round((topPx / window.innerHeight) * 10000) / 10000;
                GM_setValue('rd_button_pos_pct', { leftPct, topPct });
            } catch (e) { console.warn('Failed to save position pct', e); }
        }
        function clearSavedPositionPct() { try { GM_setValue('rd_button_pos_pct', null); } catch (e) {} }

        function clampToViewportPx(leftPx, topPx, w = 60, h = 60) {
            const minLeft = 10;
            const minTop = 10;
            const maxLeft = Math.max(minLeft, window.innerWidth - w - 10);
            const maxTop = Math.max(minTop, window.innerHeight - h - 10);
            const clampedLeft = Math.min(Math.max(minLeft, leftPx), maxLeft);
            const clampedTop = Math.min(Math.max(minTop, topPx), maxTop);
            return { left: clampedLeft, top: clampedTop };
        }

        // Apply saved pct (converted to px)
        const savedPct = getSavedPositionPct();
        if (savedPct && typeof savedPct.leftPct === 'number' && typeof savedPct.topPct === 'number') {
            let leftPx = Math.round(savedPct.leftPct * window.innerWidth);
            let topPx = Math.round(savedPct.topPct * window.innerHeight);
            const clamped = clampToViewportPx(leftPx, topPx, 60, 60);
            toolboxWrapper.style.left = clamped.left + 'px';
            toolboxWrapper.style.top = clamped.top + 'px';
        } else {
            const defaultLeft = Math.max(10, window.innerWidth - 54);
            toolboxWrapper.style.left = defaultLeft + 'px';
            toolboxWrapper.style.top = '10px';
        }

        // Collapsed button
        const collapsedBtn = document.createElement('button');
        collapsedBtn.textContent = 'RD';
        collapsedBtn.title = 'Open Real-Debrid Tools (drag to move)';
        Object.assign(collapsedBtn.style, {
            width: '44px', height: '44px', borderRadius: '50%', border: 'none',
            background: '#111', color: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
            cursor: 'grab', fontWeight: '700', fontSize: '14px', display: 'inline-block'
        });
        toolboxWrapper.appendChild(collapsedBtn);

        // Expanded content (positioned relative to wrapper)
        toolboxContent = document.createElement('div');
        Object.assign(toolboxContent.style, {
            display: 'none',
            position: 'absolute',
            left: '0px',
            top: '52px',
            marginTop: '6px',
            background: 'rgba(0,0,0,0.85)',
            color: '#fff',
            padding: '10px',
            borderRadius: '8px',
            width: '340px',
            boxShadow: '0 6px 20px rgba(0,0,0,0.4)'
        });

        // Header + drag handle (always draggable)
        const contentHeader = document.createElement('div');
        Object.assign(contentHeader.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px', cursor: 'move' });
        const title = document.createElement('div'); title.textContent = 'Real-Debrid Tools'; title.style.fontWeight = '700';
        const dragHandle = document.createElement('div'); dragHandle.textContent = '⇳'; dragHandle.title = 'Drag to move';
        Object.assign(dragHandle.style, { cursor: 'move', paddingLeft: '6px' });
        contentHeader.appendChild(title); contentHeader.appendChild(dragHandle);
        toolboxContent.appendChild(contentHeader);

        // Controls (Refresh, Token, Reset Position)
        const controls = document.createElement('div');
        controls.style.display = 'flex'; controls.style.gap = '6px'; controls.style.flexWrap = 'wrap';
        const refreshBtn = document.createElement('button'); refreshBtn.textContent = 'Refresh Regexes'; refreshBtn.onclick = async () => { await updateRDRegexes().catch(e => console.error(e)); buildDomainButtons(); showToast('Regex list refreshed'); };
        const updateTokenBtn = document.createElement('button'); updateTokenBtn.textContent = 'Update Token'; updateTokenBtn.onclick = () => updatetoken();
        const resetPosBtn = document.createElement('button'); resetPosBtn.textContent = 'Reset Position'; resetPosBtn.title = 'Reset floating button to default position';
        resetPosBtn.onclick = () => { clearSavedPositionPct(); const defaultLeft = Math.max(10, window.innerWidth - 54); toolboxWrapper.style.left = defaultLeft + 'px'; toolboxWrapper.style.top = '10px'; showToast('Position reset'); };
        controls.appendChild(refreshBtn); controls.appendChild(updateTokenBtn); controls.appendChild(resetPosBtn);
        toolboxContent.appendChild(controls);

        // Convert Selection button
        convertSelectionBtn = document.createElement('button');
        convertSelectionBtn.textContent = 'Convert Selection';
        convertSelectionBtn.style.display = 'block';
        convertSelectionBtn.style.marginTop = '8px';
        convertSelectionBtn.disabled = true;
        convertSelectionBtn.onclick = async () => {
            const sel = window.getSelection();
            const urls = extractUrlsFromSelection(sel);
            if (!urls || urls.length === 0) { showToast('No matching RD URLs in selection.'); return; }
            const grouped = {};
            urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); });
            for (const d of Object.keys(grouped)) await convertDomainLinks(d, grouped[d], []);
        };
        toolboxContent.appendChild(convertSelectionBtn);

        // Domain buttons container & other controls
        const container = document.createElement('div'); container.id = 'rd-domain-buttons-container'; container.style.marginTop = '8px'; container.style.maxHeight = '48vh'; container.style.overflow = 'auto';
        toolboxContent.appendChild(container);
        const toggleResults = document.createElement('button'); toggleResults.textContent = 'Show/Hide Results'; toggleResults.style.display = 'block'; toggleResults.style.marginTop = '8px';
        toggleResults.onclick = () => { if (!resultsPanel) createResultsPanel(); resultsPanel.style.display = resultsPanel.style.display === 'none' ? 'block' : 'none'; };
        toolboxContent.appendChild(toggleResults);
        const collapseBtn = document.createElement('button'); collapseBtn.textContent = 'Collapse'; collapseBtn.style.display = 'block'; collapseBtn.style.marginTop = '8px'; collapseBtn.onclick = () => toggleToolbox(false);
        toolboxContent.appendChild(collapseBtn);

        toolboxWrapper.appendChild(toolboxContent);
        document.body.appendChild(toolboxWrapper);

        // Toggle function that ensures content stays visible
        function toggleToolbox(expand) {
            if (expand === undefined) expand = toolboxContent.style.display === 'none';
            if (!expand) {
                toolboxContent.style.display = 'none';
                collapsedBtn.style.display = 'inline-block';
                return;
            }
            toolboxContent.style.display = 'block';
            collapsedBtn.style.display = 'none';

            const wrapperRect = toolboxWrapper.getBoundingClientRect();
            toolboxContent.style.visibility = 'hidden';
            toolboxContent.style.display = 'block';
            const contentRect = toolboxContent.getBoundingClientRect();

            let relLeft = 0;
            const overflowRight = wrapperRect.left + contentRect.width + 10 - window.innerWidth;
            if (overflowRight > 0) relLeft = -overflowRight;
            if (wrapperRect.left + relLeft < 10) relLeft = 10 - wrapperRect.left;

            const spaceBelow = window.innerHeight - (wrapperRect.top + wrapperRect.height) - 10;
            const spaceAbove = wrapperRect.top - 10;
            let relTop;
            if (contentRect.height <= spaceBelow) relTop = wrapperRect.height + 6;
            else if (contentRect.height <= spaceAbove) relTop = -contentRect.height - 6;
            else {
                const maxTop = window.innerHeight - contentRect.height - 10 - wrapperRect.top;
                relTop = Math.max(-contentRect.height, maxTop);
            }

            toolboxContent.style.left = relLeft + 'px';
            toolboxContent.style.top = relTop + 'px';
            toolboxContent.style.visibility = 'visible';
        }

        // ---- Dragging (edge/movement threshold logic) ----
        // Parameters:
        const edgeDragWidth = 10;   // px from the circular button edge that allows immediate drag
        const moveThreshold = 8;    // px movement required to begin drag when not on edge

        let pointerDown = false;
        let startClientX = 0, startClientY = 0;
        let startLeft = 0, startTop = 0;
        let dragging = false;
        let dragAllowedImmediate = false; // true when started on edge or handle
        let movedDuringInteraction = false;

        function startInteraction(clientX, clientY) {
            pointerDown = true;
            startClientX = clientX;
            startClientY = clientY;
            const rect = toolboxWrapper.getBoundingClientRect();
            startLeft = rect.left;
            startTop = rect.top;
            dragging = false;
            movedDuringInteraction = false;
            dragAllowedImmediate = false;
        }

        function beginActualDrag() {
            dragging = true;
            collapsedBtn.style.cursor = 'grabbing';
            // if the content panel is open, hide it while dragging to avoid odd measuring issues
            toolboxContent.style.display = 'none';
        }

        function doInteractionMove(clientX, clientY) {
            if (!pointerDown) return;
            const dx = clientX - startClientX;
            const dy = clientY - startClientY;
            const dist = Math.sqrt(dx * dx + dy * dy);
            movedDuringInteraction = movedDuringInteraction || dist > 0;

            if (!dragging) {
                // If immediate allowed (start on edge or handle), start drag as soon as there's any movement
                if (dragAllowedImmediate) {
                    beginActualDrag();
                } else if (dist >= moveThreshold) {
                    // movement threshold passed -> start drag
                    beginActualDrag();
                } else {
                    // not enough movement yet; don't move wrapper
                    return;
                }
            }

            // dragging is active: apply new position
            const newLeft = startLeft + dx;
            const newTop = startTop + dy;
            const clamped = clampToViewportPx(newLeft, newTop, toolboxWrapper.offsetWidth, toolboxWrapper.offsetHeight);
            toolboxWrapper.style.left = clamped.left + 'px';
            toolboxWrapper.style.top = clamped.top + 'px';
            toolboxWrapper.style.right = 'auto';
        }

        function endInteraction() {
            if (!pointerDown) return;
            pointerDown = false;
            // If we were dragging, save percentage and finish
            if (dragging) {
                dragging = false;
                collapsedBtn.style.cursor = 'grab';
                const rect = toolboxWrapper.getBoundingClientRect();
                savePositionPctFromPx(rect.left, rect.top);
                showToast('Position saved');
                // small delay to avoid immediate re-opening from the same click
                setTimeout(() => { movedDuringInteraction = false; }, 50);
                return;
            }
            // Not dragging -> this was a click/tap (only if pointer down/up without having moved past threshold)
            movedDuringInteraction = false;
            // Click behavior will be handled by the click listener on collapsedBtn
        }

        // Pointer/mouse/touch event handlers: unify using pointer events where available
        function onPointerDownForButton(e) {
            // Accept mouse and touch pointer coordinates
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            startInteraction(clientX, clientY);

            // Edge detection: check if pointer started near the circular button edge
            const btnRect = collapsedBtn.getBoundingClientRect();
            const radius = btnRect.width / 2;
            const centerX = btnRect.left + radius;
            const centerY = btnRect.top + radius;
            const distFromCenter = Math.hypot(clientX - centerX, clientY - centerY);
            // if pointer is within edgeDragWidth of the outer edge, allow immediate drag
            if (distFromCenter >= Math.max(0, radius - edgeDragWidth)) {
                dragAllowedImmediate = true;
            } else {
                dragAllowedImmediate = false;
            }

            // Add move/up listeners on document to track dragging even if pointer leaves button
            document.addEventListener('mousemove', pointerMoveHandler);
            document.addEventListener('mouseup', pointerUpHandler);
            document.addEventListener('touchmove', pointerMoveHandler, { passive: false });
            document.addEventListener('touchend', pointerUpHandler);
            // prevent default to avoid accidental text selection on desktop
            e.preventDefault && e.preventDefault();
        }

        function onPointerDownForHandle(e) {
            // handle in header: allow immediate drag
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            startInteraction(clientX, clientY);
            dragAllowedImmediate = true; // always allow when starting from the header handle
            document.addEventListener('mousemove', pointerMoveHandler);
            document.addEventListener('mouseup', pointerUpHandler);
            document.addEventListener('touchmove', pointerMoveHandler, { passive: false });
            document.addEventListener('touchend', pointerUpHandler);
            e.preventDefault && e.preventDefault();
        }

        function pointerMoveHandler(e) {
            const clientX = (e.touches && e.touches[0]) ? e.touches[0].clientX : e.clientX;
            const clientY = (e.touches && e.touches[0]) ? e.touches[0].clientY : e.clientY;
            doInteractionMove(clientX, clientY);
            if (e.cancelable) e.preventDefault();
        }

        function pointerUpHandler(e) {
            // remove listeners
            document.removeEventListener('mousemove', pointerMoveHandler);
            document.removeEventListener('mouseup', pointerUpHandler);
            document.removeEventListener('touchmove', pointerMoveHandler);
            document.removeEventListener('touchend', pointerUpHandler);
            endInteraction();
        }

        // Start drag on collapsed button or header handle
        collapsedBtn.addEventListener('mousedown', onPointerDownForButton);
        collapsedBtn.addEventListener('touchstart', onPointerDownForButton, { passive: false });
        dragHandle.addEventListener('mousedown', onPointerDownForHandle);
        dragHandle.addEventListener('touchstart', onPointerDownForHandle, { passive: false });

        // collapsed button click toggles toolbox only if we didn't start a drag
        collapsedBtn.addEventListener('click', (ev) => {
            // If pointerDown triggers drag or movement happened, ignore click
            // movedDuringInteraction is set when movement occurs; pointerDown false here means interaction ended without dragging
            // However to be safe, check dragging state - if a drag was started recently we suppressed click by not performing open
            if (dragging || movedDuringInteraction) {
                // reset moved flag and ignore
                movedDuringInteraction = false;
                return;
            }
            // otherwise open toolbox
            toggleToolbox(true);
            buildDomainButtons();
        });

        // close toolbox when clicking outside
        document.addEventListener('click', (ev) => {
            if (!toolboxWrapper.contains(ev.target) && toolboxContent.style.display === 'block') {
                toolboxContent.style.display = 'none';
                collapsedBtn.style.display = 'inline-block';
            }
        });

        // On window resize: reapply saved percentages (if any) or clamp current px and save
        window.addEventListener('resize', () => {
            const saved = getSavedPositionPct();
            if (saved && typeof saved.leftPct === 'number' && typeof saved.topPct === 'number') {
                let leftPx = Math.round(saved.leftPct * window.innerWidth);
                let topPx = Math.round(saved.topPct * window.innerHeight);
                const clamped = clampToViewportPx(leftPx, topPx, 60, 60);
                toolboxWrapper.style.left = clamped.left + 'px';
                toolboxWrapper.style.top = clamped.top + 'px';
            } else {
                const rect = toolboxWrapper.getBoundingClientRect();
                const clamped = clampToViewportPx(rect.left, rect.top, rect.width, rect.height);
                toolboxWrapper.style.left = clamped.left + 'px';
                toolboxWrapper.style.top = clamped.top + 'px';
                savePositionPctFromPx(clamped.left, clamped.top);
            }
        });

        return toolboxWrapper;
    }

    // ---- domain buttons ----
    function clearDomainButtons() {
        const container = document.getElementById('rd-domain-buttons-container');
        if (container) container.innerHTML = '';
    }

    function buildDomainButtons() {
        createToolbox();
        clearDomainButtons();
        const container = document.getElementById('rd-domain-buttons-container');

        if (!isRegexListLoaded()) {
            const msg = document.createElement('div'); msg.textContent = 'Regex list not loaded. Click "Refresh Regexes".'; container.appendChild(msg); return;
        }

        const found = findMatchingHostsOnPage();
        const hosts = Object.keys(found);
        if (!hosts.length) {
            const none = document.createElement('div'); none.textContent = 'No matching RD links detected on this page.'; container.appendChild(none); return;
        }

        hosts.forEach(host => {
            const info = found[host];
            const btn = document.createElement('button');
            btn.textContent = `${host} (${info.urls.length})`;
            btn.style.display = 'block'; btn.style.marginTop = '6px';
            btn.onclick = async () => { await convertDomainLinks(host, info.urls, info.anchors); };
            container.appendChild(btn);
        });

        const allBtn = document.createElement('button'); allBtn.textContent = 'Convert ALL matched links on page'; allBtn.style.display = 'block'; allBtn.style.marginTop = '8px';
        allBtn.onclick = async () => { for (const host of hosts) await convertDomainLinks(host, found[host].urls, found[host].anchors); };
        container.appendChild(allBtn);
    }

    // ---- conversion routine (updates results UI in-place) ----
    async function convertDomainLinks(domain, urls, anchors = []) {
        if (!Array.isArray(urls) || urls.length === 0) { showToast(`No links to convert for ${domain}`); return; }
        showToast(`Converting ${urls.length} links for ${domain}...`, 2000);

        // ensure top entries exist and set pending
        urls.forEach(u => updateResultStatus(u, 'pending', 'pending'));

        for (const url of urls) {
            if (processedURLs.has(url)) {
                updateResultStatus(url, 'skip', 'skipped');
                continue;
            }
            updateResultStatus(url, 'pending', 'processing');
            const res = await rdUnrestrict(url);
            if (res.success) {
                updateResultStatus(url, 'ok', 'OK');
                // update anchors on page
                anchors.forEach(a => { try { if (a.href === url) { a.href = res.download; if (res.filename) a.textContent = res.filename; a.setAttribute('data-rd-converted', '1'); } } catch (e) {} });
                // replace in textareas / pre blocks
                document.querySelectorAll('textarea').forEach(t => { if (t.value && t.value.includes(url)) t.value = t.value.split(url).join(res.download); });
                document.querySelectorAll('pre, code').forEach(el => { if ((el.textContent || '').includes(url)) el.textContent = (el.textContent || '').split(url).join(res.download); });
                processedURLs.add(url);
                // add the download link to successful textarea
                appendSuccessfulDownload(res.download);
            } else {
                updateResultStatus(url, 'fail', 'FAILED');
                // include error as title on status for tooltip
                const entry = resultsEntries.get(url);
                if (entry && entry.statusEl) entry.statusEl.title = res.error;
            }
            await new Promise(r => setTimeout(r, 180));
        }
        showToast(`Done converting ${domain}`);
    }

    // ---- selection/context menu extraction with regexes ----
    function extractUrlsFromSelection(sel) {
        const urls = new Set();
        if (!sel || !isRegexListLoaded()) return [];
        try {
            if (sel.rangeCount && sel.rangeCount > 0) {
                for (let i = 0; i < sel.rangeCount; i++) {
                    const range = sel.getRangeAt(i);
                    const frag = range.cloneContents();
                    if (frag.querySelectorAll && frag.querySelectorAll('a[href]').length) {
                        frag.querySelectorAll('a[href]').forEach(a => {
                            let href = a.getAttribute('href') || '';
                            if (!href) return;
                            try { href = new URL(href, document.baseURI).href; } catch (e) {}
                            if (urlMatchesAnyRegex(href)) urls.add(href);
                        });
                    }
                    const txt = (frag.textContent || '').trim();
                    if (txt) extractUrlsUsingRegexesFromText(txt).forEach(u => urls.add(u));
                    else { const plain = sel.toString(); if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u)); }
                }
            } else {
                const plain = sel.toString();
                if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u));
            }
        } catch (e) {
            const plain = sel.toString();
            if (plain) extractUrlsUsingRegexesFromText(plain).forEach(u => urls.add(u));
        }
        return Array.from(urls);
    }

    let customMenu = null;
    function hideCustomMenu() { if (customMenu && customMenu.parentNode) customMenu.parentNode.removeChild(customMenu); customMenu = null; }

    function onContextMenu(e) {
        const sel = window.getSelection();
        const urls = extractUrlsFromSelection(sel);
        if (!urls || !urls.length) { hideCustomMenu(); return; }
        e.preventDefault();
        hideCustomMenu();
        customMenu = document.createElement('div');
        Object.assign(customMenu.style, { position: 'fixed', zIndex: '99999999', left: `${e.clientX}px`, top: `${e.clientY}px`, background: '#111', color: '#fff', padding: '8px', borderRadius: '6px', boxShadow: '0 6px 20px rgba(0,0,0,0.4)', fontSize: '13px' });
        const title = document.createElement('div'); title.textContent = `Convert ${urls.length} selected RD link(s)`; title.style.fontWeight = '700'; title.style.marginBottom = '6px';
        customMenu.appendChild(title);

        const allBtn = document.createElement('button'); allBtn.textContent = 'Convert all selected links'; allBtn.style.display = 'block';
        allBtn.onclick = async () => { hideCustomMenu(); const grouped = {}; urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); }); for (const d of Object.keys(grouped)) await convertDomainLinks(d, grouped[d], []); };
        customMenu.appendChild(allBtn);

        const grouped = {};
        urls.forEach(u => { const d = urlDomain(u); if (!grouped[d]) grouped[d] = []; grouped[d].push(u); });

        Object.keys(grouped).forEach(d => {
            const btn = document.createElement('button'); btn.textContent = `Convert ${d} (${grouped[d].length})`; btn.style.display = 'block'; btn.style.marginTop = '6px';
            btn.onclick = async () => { hideCustomMenu(); await convertDomainLinks(d, grouped[d], []); };
            customMenu.appendChild(btn);
        });

        const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; cancelBtn.style.display = 'block'; cancelBtn.style.marginTop = '6px'; cancelBtn.onclick = () => hideCustomMenu();
        customMenu.appendChild(cancelBtn);

        document.body.appendChild(customMenu);
    }

    function installSelectionContextHandler() {
        document.addEventListener('contextmenu', onContextMenu);
        document.addEventListener('click', () => hideCustomMenu());
        window.addEventListener('blur', () => hideCustomMenu());
        document.addEventListener('selectionchange', () => {
            if (!convertSelectionBtn) return;
            const sel = window.getSelection();
            const urls = extractUrlsFromSelection(sel);
            if (urls && urls.length) { convertSelectionBtn.disabled = false; convertSelectionBtn.textContent = `Convert Selection (${urls.length})`; }
            else { convertSelectionBtn.disabled = true; convertSelectionBtn.textContent = 'Convert Selection'; }
        });
    }

    // ---- per-link buttons (only for matches) ----
    function createFastDownloadButton(linkElement, fileURL) {
        if (!linkElement || linkElement.getAttribute('realdebrid')) return;
        if (!urlMatchesAnyRegex(fileURL)) return;
        const button = document.createElement('button');
        button.innerHTML = 'Send to RD';
        Object.assign(button.style, { marginLeft: '6px', padding: '2px 6px', backgroundColor: '#000', color: '#fff', borderRadius: '6px', border: 'none', cursor: 'pointer' });
        button.onclick = async (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            button.disabled = true; button.textContent = 'Sending...';
            // ensure result entry present
            updateResultStatus(fileURL, 'pending', 'sending');
            const res = await rdUnrestrict(fileURL);
            if (res.success) {
                try { linkElement.href = res.download; if (res.filename) linkElement.textContent = res.filename; } catch (e) {}
                updateResultStatus(fileURL, 'ok', 'OK');
                appendSuccessfulDownload(res.download);
                button.remove();
            } else {
                updateResultStatus(fileURL, 'fail', 'FAILED');
                const entry = resultsEntries.get(fileURL);
                if (entry && entry.statusEl) entry.statusEl.title = res.error;
                button.textContent = 'Failed - ' + res.errmsg;
                setTimeout(() => button.disabled = false, 2000);
            }
        };
        linkElement.setAttribute('realdebrid', 'true');
        linkElement.insertAdjacentElement('afterend', button);
    }

    function createMagnetButton(linkElement, fileURL) {
        if (!linkElement || linkElement.getAttribute('realdebrid-magnet')) return;
        const button = document.createElement('button');
        button.innerHTML = 'Send Magnet to RD';
        Object.assign(button.style, { marginLeft: '6px', padding: '2px 6px', backgroundColor: 'green', color: '#fff', borderRadius: '6px', border: 'none', cursor: 'pointer' });
        button.onclick = async (ev) => {
            ev.preventDefault(); ev.stopPropagation();
            button.disabled = true;
            updateResultStatus(fileURL, 'pending', 'adding magnet');
            appendResultLine ? appendResultLine(`[magnet] adding ${fileURL}...`) : null;
            try {
                const addResp = await gmRequest({
                    method: 'POST',
                    url: 'https://api.real-debrid.com/rest/1.0/torrents/addMagnet',
                    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
                    data: `magnet=${encodeURIComponent(fileURL)}`
                });
                if (addResp.status === 201) {
                    const json = JSON.parse(addResp.responseText);
                    const torrentId = json.id;
                    updateResultStatus(fileURL, 'ok', 'magnet added');
                    appendResultLine ? appendResultLine(`[magnet] added ID ${torrentId}`, true) : null;
                    const selectResp = await gmRequest({
                        method: 'POST',
                        url: `https://api.real-debrid.com/rest/1.0/torrents/selectFiles/${torrentId}`,
                        headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
                        data: 'files=all'
                    });
                    if (selectResp.status === 200 || selectResp.status === 204) appendResultLine ? appendResultLine(`[magnet] Selected all files for ${torrentId}`, true) : null;
                    else appendResultLine ? appendResultLine(`[magnet] Failed to select files: ${selectResp.status}`, false) : null;
                } else {
                    updateResultStatus(fileURL, 'fail', 'FAILED');
                    appendResultLine ? appendResultLine(`[magnet] Failed to add magnet: ${addResp.status} ${addResp.responseText}`, false) : null;
                }
            } catch (e) {
                updateResultStatus(fileURL, 'fail', 'ERROR');
                appendResultLine ? appendResultLine(`[magnet] Error: ${e && e.message ? e.message : e}`, false) : null;
            }
            button.remove();
        };
        linkElement.setAttribute('realdebrid-magnet', 'true');
        linkElement.insertAdjacentElement('afterend', button);
    }

    // ---- process page links & pre blocks ----
    function processLinks() {
        document.querySelectorAll('a[href]').forEach(link => {
            try {
                const href = link.href;
                if (!href) return;
                if (href.startsWith('magnet:?')) { if (!link.hasAttribute('realdebrid-magnet')) createMagnetButton(link, href); }
                else { if (!link.hasAttribute('realdebrid') && urlMatchesAnyRegex(href)) createFastDownloadButton(link, href); }
            } catch (e) {}
        });

        document.querySelectorAll('pre').forEach(pre => {
            if (pre.getAttribute('rd-processed')) return;
            const txt = pre.textContent || '';
            const urls = extractUrlsUsingRegexesFromText(txt);
            if (!urls || !urls.length) { pre.setAttribute('rd-processed', '1'); return; }
            const container = document.createElement('div');
            urls.forEach(u => {
                const a = document.createElement('a'); a.href = u; a.textContent = u; a.style.display = 'block'; a.style.wordBreak = 'break-all';
                container.appendChild(a);
                createFastDownloadButton(a, u);
            });
            pre.parentNode.insertBefore(container, pre);
            pre.setAttribute('rd-processed', '1');
        });
    }

    // ---- mutation observer ----
    function debounce(fn, wait = 450) { let t = null; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); }; }
    const observer = new MutationObserver(debounce(() => { processLinks(); buildDomainButtons(); }, 450));

    // ---- RD regex fetch ----
    async function updateRDRegexes() {
        try {
            const bearer = GM_getValue('api_token', '') || token || '';
            if (!bearer) { showToast('No API token. Use Update Token.'); return []; }
            const response = await gmRequest({ method: 'GET', url: 'https://api.real-debrid.com/rest/1.0/hosts/regex', headers: { 'Authorization': `Bearer ${bearer}` } });
            if (response.status === 200) {
                const arr = JSON.parse(response.responseText);
                if (Array.isArray(arr) && arr.length) {
                    targetRegexStrings = arr;
                    GM_setValue('targetRegexStrings', targetRegexStrings);
                    GM_setValue('lastUpdateTimestamp', Date.now());
                    compileAllRegexes();
                    return arr;
                } else { showToast('No regexes returned.'); return []; }
            } else {
                showToast(`Failed to fetch regexes: ${response.status}`);
                return [];
            }
        } catch (e) {
            console.error(e);
            showToast('Error updating regex list');
            throw e;
        }
    }

    // ---- token update ----
    async function updatetoken() {
        try {
            const response = await gmRequest({ method: 'GET', url: 'https://real-debrid.com/apitoken' });
            if (response.status === 200) {
                const text = response.responseText || '';
                const match = text.match(/document\.querySelectorAll\('input\[name=private_token\]'\)\[0\]\.value\s*=\s*'([^']+)'/);
                if (match && match[1]) {
                    token = match[1];
                    GM_setValue('api_token', token);
                    showToast('API token updated automatically.');
                    return token;
                } else {
                    const manual = prompt('API token not found automatically. Please paste your Real-Debrid API token:');
                    if (manual) { token = manual.trim(); GM_setValue('api_token', token); showToast('API token saved.'); return token; }
                    showToast('API token not set.'); return null;
                }
            } else { showToast('Failed to fetch token page.'); return null; }
        } catch (e) { console.error(e); showToast('Error updating token.'); return null; }
    }

    function ensureUpdateDDLDomains() {
        const last = GM_getValue('lastUpdateTimestamp', 0);
        const now = Date.now();
        const msPerDay = 24 * 60 * 60 * 1000;
        if (now - last >= msPerDay) updateRDRegexes().catch(e => console.error(e));
    }

    // ---- init ----
    function init() {
        token = GM_getValue('api_token', '') || token;
        targetRegexStrings = GM_getValue('targetRegexStrings', targetRegexStrings || []);
        compileAllRegexes();
        createToolbox();
        buildDomainButtons();
        processLinks();
        installSelectionContextHandler();
        observer.observe(document.body, { childList: true, subtree: true });
        ensureUpdateDDLDomains();
        try { GM_registerMenuCommand('Update API Token', updatetoken); GM_registerMenuCommand('Refresh RD Regexes', updateRDRegexes); } catch (e) {}
    }

    if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', init); else init();

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址