您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.3 // @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', padding: '10px', borderRadius: '8px', width: '560px', maxHeight: '70vh', // 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', padding: '4px', borderRadius: '6px', maxHeight: '25vh', // 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' }); // Helper: saved position stored as percentages (vw/vh) 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; // keep a few decimals 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) {} } // Clamp px position to viewport with 10px margins 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 }; } // Convert saved pct to px and apply clamp 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 { // default: top-right-ish (use left coordinate so we can store percent) 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 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 (clears saved pct) 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'); }; controls.appendChild(refreshBtn); const updateTokenBtn = document.createElement('button'); updateTokenBtn.textContent = 'Update Token'; updateTokenBtn.onclick = () => updatetoken(); controls.appendChild(updateTokenBtn); 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(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 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); // Show/Hide Results 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); // Collapse button 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 toolbox while ensuring it 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; } // show first to measure toolboxContent.style.display = 'block'; collapsedBtn.style.display = 'none'; // measure wrapper and content const wrapperRect = toolboxWrapper.getBoundingClientRect(); toolboxContent.style.visibility = 'hidden'; toolboxContent.style.display = 'block'; const contentRect = toolboxContent.getBoundingClientRect(); // horizontal: shift left if overflowing right, ensure not off left edge 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; // vertical placement: prefer below, else above, else clamp 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'; } // Drag implementation (mouse + touch). Save as percentages at end. let isDragging = false, dragStartX = 0, dragStartY = 0, startLeft = 0, startTop = 0, moved = false; function startDrag(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; isDragging = true; moved = false; dragStartX = clientX; dragStartY = clientY; const rect = toolboxWrapper.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; collapsedBtn.style.cursor = 'grabbing'; document.addEventListener('mousemove', doDrag); document.addEventListener('mouseup', stopDrag); document.addEventListener('touchmove', doDrag, { passive: false }); document.addEventListener('touchend', stopDrag); e.preventDefault && e.preventDefault(); } function doDrag(e) { if (!isDragging) return; 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; const dx = clientX - dragStartX, dy = clientY - dragStartY; let newLeft = startLeft + dx, 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'; moved = true; if (e.cancelable) e.preventDefault(); } function stopDrag() { if (!isDragging) return; isDragging = false; collapsedBtn.style.cursor = 'grab'; document.removeEventListener('mousemove', doDrag); document.removeEventListener('mouseup', stopDrag); document.removeEventListener('touchmove', doDrag); document.removeEventListener('touchend', stopDrag); if (moved) { const rect = toolboxWrapper.getBoundingClientRect(); savePositionPctFromPx(rect.left, rect.top); showToast('Position saved'); } } // Start drag from collapsed button or header handle collapsedBtn.addEventListener('mousedown', startDrag); collapsedBtn.addEventListener('touchstart', startDrag, { passive: false }); dragHandle.addEventListener('mousedown', startDrag); dragHandle.addEventListener('touchstart', startDrag, { passive: false }); // collapsed button click toggles toolbox unless we just dragged collapsedBtn.addEventListener('click', (ev) => { if (moved) { moved = false; return; } toggleToolbox(true); buildDomainButtons(); }); // close 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 resize: if saved percentages exist, recompute px from them // Otherwise, clamp current px and save resulting percentages 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, toolboxWrapper.offsetWidth, toolboxWrapper.offsetHeight); 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'; // persist the (new) pct so future resizes keep relative placement 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或关注我们的公众号极客氢云获取最新地址