您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically send magnet links to Real-Debrid
// ==UserScript== // @name Magnet Link to Real-Debrid // @version 2.3.0 // @description Automatically send magnet links to Real-Debrid // @author Journey Over // @license MIT // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect api.real-debrid.com // @icon https://www.google.com/s2/favicons?sz=64&domain=real-debrid.com // @homepageURL https://github.com/StylusThemes/Userscripts // @namespace https://greasyfork.org/users/32214 // ==/UserScript== (() => { 'use strict'; /* Constants & Utilities */ const STORAGE_KEY = 'realDebridConfig'; const API_BASE = 'https://api.real-debrid.com/rest/1.0'; const ICON_SRC = 'https://fcdn.real-debrid.com/0830/favicons/favicon.ico'; const INSERTED_ICON_ATTR = 'data-rd-inserted'; const DEFAULTS = { apiKey: '', allowedExtensions: ['mp3', 'm4b', 'mp4', 'mkv', 'cbz', 'cbr'], filterKeywords: ['sample', 'bloopers', 'trailer'], debugMode: false }; // Simple debounce helper for DOM mutation handling const debounce = (fn, ms = 120) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; }; /* Errors */ class ConfigurationError extends Error { constructor(message) { super(message); this.name = 'ConfigurationError'; } } class RealDebridError extends Error { constructor(message, statusCode = null) { super(message); this.name = 'RealDebridError'; this.statusCode = statusCode; } } /* Config Manager */ class ConfigManager { // Parse stored JSON safely, falling back to null on failure static _safeParse(value) { if (!value) return null; try { return typeof value === 'string' ? JSON.parse(value) : value; } catch (e) { console.warn('Config parse failed, resetting to defaults.', e); return null; } } static getConfig() { const stored = GM_getValue(STORAGE_KEY); const parsed = this._safeParse(stored) || {}; return { ...DEFAULTS, ...parsed }; } // Persist configuration; API key required static saveConfig(cfg) { if (!cfg || !cfg.apiKey) throw new ConfigurationError('API Key is required'); GM_setValue(STORAGE_KEY, JSON.stringify(cfg)); } static validateConfig(cfg) { const errors = []; if (!cfg || !cfg.apiKey) errors.push('API Key is missing'); if (!Array.isArray(cfg.allowedExtensions)) errors.push('allowedExtensions must be an array'); if (!Array.isArray(cfg.filterKeywords)) errors.push('filterKeywords must be an array'); return errors; } } /* Real-Debrid Service (wraps GM_xmlhttpRequest) */ class RealDebridService { #apiKey; #debug; constructor(apiKey, { debugMode = false } = {}) { if (!apiKey) throw new ConfigurationError('API Key required'); this.#apiKey = apiKey; this.#debug = Boolean(debugMode); } #log(...args) { if (this.#debug) console.log('[RealDebridService]', ...args); } // Generic request wrapper: handles headers, encoding and JSON parsing/errors #request(method, endpoint, data = null) { return new Promise((resolve, reject) => { const url = `${API_BASE}${endpoint}`; const payload = data ? new URLSearchParams(data).toString() : null; this.#log('request', method, url, data); GM_xmlhttpRequest({ method, url, headers: { Authorization: `Bearer ${this.#apiKey}`, Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, data: payload, onload: (resp) => { this.#log('response', resp.status, resp.responseText && resp.responseText.slice && resp.responseText.slice(0, 500)); if (!resp || typeof resp.status === 'undefined') { return reject(new RealDebridError('Invalid API response')); } if (resp.status < 200 || resp.status >= 300) { const msg = resp.responseText ? resp.responseText : `HTTP ${resp.status}`; return reject(new RealDebridError(`API Error: ${msg}`, resp.status)); } if (resp.status === 204 || !resp.responseText) return resolve({}); try { const parsed = JSON.parse(resp.responseText.trim()); return resolve(parsed); } catch (e) { this.#log('parse error', e); return reject(new RealDebridError(`Failed to parse API response: ${e.message}`, resp.status)); } }, onerror: (err) => { this.#log('network error', err); return reject(new RealDebridError('Network request failed')); }, ontimeout: () => { this.#log('timeout'); return reject(new RealDebridError('Request timed out')); } }); }); } addMagnet(magnet) { return this.#request('POST', '/torrents/addMagnet', { magnet }); } getTorrentInfo(torrentId) { return this.#request('GET', `/torrents/info/${torrentId}`); } selectFiles(torrentId, filesCsv) { return this.#request('POST', `/torrents/selectFiles/${torrentId}`, { files: filesCsv }); } getExistingTorrents() { // Gracefully return an empty array on failure return this.#request('GET', '/torrents').catch(() => []); } } /* Magnet Processing */ class MagnetLinkProcessor { #config; #api; #existing = []; constructor(config, api) { this.#config = config; this.#api = api; } async initialize() { try { this.#existing = await this.#api.getExistingTorrents(); if (this.#config.debugMode) console.log('[MagnetLinkProcessor] existing torrents', this.#existing); } catch (e) { console.warn('Failed to load existing torrents', e); this.#existing = []; } } // Extract BTIH (hash) from magnet link static parseMagnetHash(magnetLink) { if (!magnetLink || typeof magnetLink !== 'string') return null; try { const qIdx = magnetLink.indexOf('?'); const qs = qIdx >= 0 ? magnetLink.slice(qIdx + 1) : magnetLink; const params = new URLSearchParams(qs); const xt = params.get('xt'); if (xt) { const match = xt.match(/urn:btih:([A-Za-z0-9]+)/i); if (match) return match[1].toUpperCase(); } const fallback = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i); if (fallback) return fallback[1].toUpperCase(); return null; } catch (e) { const m = magnetLink.match(/xt=urn:btih:([A-Za-z0-9]+)/i); return m ? m[1].toUpperCase() : null; } } isTorrentExists(hash) { if (!hash) return false; return Array.isArray(this.#existing) && this.#existing.some(t => (t.hash || '').toUpperCase() === hash); } // Filter torrent files by allowed extensions and filter keywords (supports regex-like /.../) filterFiles(files = []) { const allowed = new Set(this.#config.allowedExtensions.map(s => s.trim().toLowerCase()).filter(Boolean)); const keywords = (this.#config.filterKeywords || []).map(k => k.trim()).filter(Boolean); return (files || []).filter(file => { const path = (file.path || '').toLowerCase(); const name = path.split('/').pop() || ''; const ext = name.includes('.') ? name.split('.').pop() : ''; if (!allowed.has(ext)) return false; for (const kw of keywords) { if (!kw) continue; if (kw.startsWith('/') && kw.endsWith('/')) { try { const re = new RegExp(kw.slice(1, -1), 'i'); if (re.test(path) || re.test(name)) return false; } catch (e) { // invalid regex: ignore it } } if (path.includes(kw.toLowerCase()) || name.includes(kw.toLowerCase())) return false; } return true; }); } async processMagnetLink(magnetLink) { const hash = MagnetLinkProcessor.parseMagnetHash(magnetLink); if (!hash) throw new RealDebridError('Invalid magnet link'); if (this.isTorrentExists(hash)) throw new RealDebridError('Torrent already exists on Real-Debrid'); const addResult = await this.#api.addMagnet(magnetLink); if (!addResult || typeof addResult.id === 'undefined') { throw new RealDebridError('Failed to add magnet'); } const torrentId = addResult.id; const info = await this.#api.getTorrentInfo(torrentId); const files = Array.isArray(info.files) ? info.files : []; const chosen = this.filterFiles(files).map(f => f.id); if (!chosen.length) throw new RealDebridError('No matching files found after filtering'); await this.#api.selectFiles(torrentId, chosen.join(',')); return chosen.length; } } /* UI Manager */ class UIManager { // Build and return modal dialog DOM. Caller must append it to document. static createConfigDialog(currentConfig) { const dialog = document.createElement('div'); dialog.innerHTML = ` <div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);display:flex;justify-content:center;align-items:center;z-index:10000;font-family:Arial,sans-serif;"> <div style="background:#1e1e2f;color:#ffffff;padding:35px;border-radius:16px;max-width:500px;width:90%;box-shadow:0 10px 30px rgba(0,0,0,0.5);border:1px solid #2c2c3a;"> <h2 style="text-align:center;color:#4db6ac;margin-bottom:25px;border-bottom:2px solid #4db6ac;padding-bottom:12px;">Real-Debrid Configuration</h2> <div style="margin-bottom:20px;"> <label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">API Key</label> <input type="text" id="apiKey" placeholder="Enter your Real-Debrid API Key" value="${currentConfig.apiKey}" style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;"> </div> <div style="margin-bottom:20px;"> <label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">Allowed Extensions</label> <textarea id="extensions" placeholder="Enter file extensions to allow" style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;min-height:80px;">${currentConfig.allowedExtensions.join(',')}</textarea> <small style="color:#80cbc4;display:block;margin-top:6px;">Separate extensions with commas (e.g., mp4,mkv,avi)</small> </div> <div style="margin-bottom:20px;"> <label style="display:block;margin-bottom:8px;font-weight:bold;color:#b2ebf2;">Filter Keywords</label> <textarea id="keywords" placeholder="Enter keywords to filter out" style="width:100%;padding:12px;border:1px solid #4db6ac;border-radius:8px;background-color:#2c2c3a;color:#ffffff;min-height:80px;">${currentConfig.filterKeywords.join(',')}</textarea> <small style="color:#80cbc4;display:block;margin-top:6px;">Separate keywords with commas (e.g., sample, /trailer/, /featurette?s/)</small> </div> <div style="margin-bottom:20px;"> <label style="display:flex;align-items:center;color:#b2ebf2;"> <input type="checkbox" id="debugMode" ${currentConfig.debugMode ? 'checked' : ''} style="margin-right:12px;width:18px;height:18px;border-radius:4px;background-color:#2c2c3a;border:2px solid #4db6ac;"> Enable Debug Mode </label> <small style="color:#80cbc4;display:block;margin-top:6px;">Provides additional logging in the browser console</small> </div> <div style="display:flex;justify-content:space-between;margin-top:25px;"> <button id="saveBtn" style="background:#4db6ac;color:#1e1e2f;border:none;padding:12px 24px;border-radius:8px;cursor:pointer;transition:all 0.3s ease-in-out;font-weight:bold;">Save</button> <button id="cancelBtn" style="background:#e57373;color:#1e1e2f;border:none;padding:12px 24px;border-radius:8px;cursor:pointer;transition:all 0.3s ease-in-out;font-weight:bold;">Cancel</button> </div> </div> </div> `; const saveBtn = dialog.querySelector('#saveBtn'); const cancelBtn = dialog.querySelector('#cancelBtn'); saveBtn.addEventListener('mouseover', () => saveBtn.style.background = '#2980b9'); saveBtn.addEventListener('mouseout', () => saveBtn.style.background = '#4db6ac'); cancelBtn.addEventListener('mouseover', () => cancelBtn.style.background = '#c0392b'); cancelBtn.addEventListener('mouseout', () => cancelBtn.style.background = '#e57373'); // ESC key handler: remove dialog on Escape const escHandler = (e) => { if (e.key === 'Escape') { if (dialog.parentNode) dialog.parentNode.removeChild(dialog); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); dialog._escHandler = escHandler; return dialog; } static showToast(message, type = 'info') { const colors = { success: '#16a34a', error: '#dc2626', info: '#2563eb' }; const msgDiv = document.createElement('div'); Object.assign(msgDiv.style, { position: 'fixed', bottom: '20px', left: '20px', backgroundColor: colors[type] || colors.info, color: 'white', padding: '10px 14px', borderRadius: '8px', zIndex: 10000, fontWeight: '600' }); msgDiv.textContent = message; document.body.appendChild(msgDiv); setTimeout(() => msgDiv.remove(), 3000); } static createMagnetIcon() { const icon = document.createElement('img'); icon.src = ICON_SRC; icon.style.cursor = 'pointer'; icon.style.width = '16px'; icon.style.marginLeft = '5px'; icon.setAttribute(INSERTED_ICON_ATTR, '1'); return icon; } } /* Page Integration: find magnet links & insert icons (one icon per unique magnet) */ class PageIntegrator { constructor(processor = null) { this.processor = processor; this.observer = null; this.config = ConfigManager.getConfig(); this.keyToIcon = new Map(); this._populateFromDOM(); } setProcessor(processor) { this.processor = processor; } _populateFromDOM() { try { const links = Array.from(document.querySelectorAll('a[href^="magnet:"]')); links.forEach(link => { const next = link.nextElementSibling; if (next && next.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) { const key = this._magnetKeyFor(link.href) || `href:${link.href.trim().toLowerCase()}`; if (!this.keyToIcon.has(key)) this.keyToIcon.set(key, next); } }); } catch (e) { // ignore DOM inspection errors } } _magnetKeyFor(href) { const hash = MagnetLinkProcessor.parseMagnetHash(href); if (hash) return `hash:${hash}`; try { return `href:${href.trim().toLowerCase()}`; } catch { return `href:${String(href).trim().toLowerCase()}`; } } // Attach click behavior to the icon: lazily initializes API and processes magnet _attach(icon, link) { icon.addEventListener('click', async (ev) => { ev.preventDefault(); const key = this._magnetKeyFor(link.href); const ok = await ensureApiInitialized(); if (!ok) { UIManager.showToast('Real-Debrid API key not configured. Use the menu to set it.', 'info'); return; } if (key && key.startsWith('hash:') && this.processor && this.processor.isTorrentExists(key.split(':')[1])) { UIManager.showToast('Torrent already exists on Real-Debrid', 'info'); icon.title = 'Already on Real-Debrid'; icon.style.filter = 'grayscale(100%)'; icon.style.opacity = '0.65'; return; } try { const count = await this.processor.processMagnetLink(link.href); UIManager.showToast(`Added to Real-Debrid — ${count} file(s) selected`, 'success'); icon.style.filter = 'grayscale(100%)'; icon.style.opacity = '0.65'; icon.title = 'Added to Real-Debrid'; } catch (err) { UIManager.showToast(err && err.message ? err.message : 'Failed to process magnet', 'error'); console.error(err); } }, { once: false }); } addIconsTo(documentRoot = document) { const links = Array.from(documentRoot.querySelectorAll('a[href^="magnet:"]')); const newlyAddedKeys = []; links.forEach(link => { if (!link.parentNode) return; const next = link.nextElementSibling; if (next && next.getAttribute && next.getAttribute(INSERTED_ICON_ATTR)) return; const key = this._magnetKeyFor(link.href); if (key && this.keyToIcon.has(key)) return; const icon = UIManager.createMagnetIcon(); this._attach(icon, link); link.parentNode.insertBefore(icon, link.nextSibling); const storeKey = key || `href:${link.href.trim().toLowerCase()}`; this.keyToIcon.set(storeKey, icon); newlyAddedKeys.push(storeKey); }); if (newlyAddedKeys.length) { ensureApiInitialized().then(ok => { if (ok) this.markExistingTorrents(); }); } } markExistingTorrents() { if (!this.processor) return; for (const [key, icon] of this.keyToIcon.entries()) { if (!key.startsWith('hash:')) continue; const hash = key.split(':')[1]; if (this.processor.isTorrentExists(hash)) { icon.title = 'Already on Real-Debrid'; icon.style.filter = 'grayscale(100%)'; icon.style.opacity = '0.65'; } } } startObserving() { if (this.observer) return; this.observer = new MutationObserver(debounce((mutations) => { for (const m of mutations) { if (m.addedNodes && m.addedNodes.length) { this.addIconsTo(document); break; } } }, 180)); this.observer.observe(document.body, { childList: true, subtree: true }); } stopObserving() { if (!this.observer) return; this.observer.disconnect(); this.observer = null; } } /* Lazy API initialization - only when needed; cached promise so it runs once */ let _apiInitPromise = null; let _apiAvailable = false; let _realDebridService = null; let _magnetProcessor = null; let _integratorInstance = null; async function ensureApiInitialized() { if (_apiInitPromise) return _apiInitPromise; const cfg = ConfigManager.getConfig(); if (!cfg.apiKey) { _apiAvailable = false; return Promise.resolve(false); } try { _realDebridService = new RealDebridService(cfg.apiKey, cfg); } catch (e) { console.warn('RealDebridService not created:', e); _apiAvailable = false; return Promise.resolve(false); } _magnetProcessor = new MagnetLinkProcessor(cfg, _realDebridService); _apiInitPromise = _magnetProcessor.initialize() .then(() => { _apiAvailable = true; if (_integratorInstance) { _integratorInstance.setProcessor(_magnetProcessor); _integratorInstance.markExistingTorrents(); } return true; }) .catch(err => { console.warn('Failed to initialize Real-Debrid integration', err); _apiAvailable = false; return false; }); return _apiInitPromise; } /* Initialization & Menu */ async function init() { try { _integratorInstance = new PageIntegrator(null); _integratorInstance.addIconsTo(); _integratorInstance.startObserving(); GM_registerMenuCommand('Configure Real-Debrid Settings', () => { const currentCfg = ConfigManager.getConfig(); const dialog = UIManager.createConfigDialog(currentCfg); document.body.appendChild(dialog); const saveBtn = dialog.querySelector('#saveBtn'); const cancelBtn = dialog.querySelector('#cancelBtn'); saveBtn.addEventListener('click', () => { const newCfg = { apiKey: dialog.querySelector('#apiKey').value.trim(), allowedExtensions: dialog.querySelector('#extensions').value.split(',').map(e => e.trim()).filter(Boolean), filterKeywords: dialog.querySelector('#keywords').value.split(',').map(k => k.trim()).filter(Boolean), debugMode: dialog.querySelector('#debugMode').checked }; try { ConfigManager.saveConfig(newCfg); if (dialog.parentNode) document.body.removeChild(dialog); if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler); UIManager.showToast('Configuration saved successfully!', 'success'); location.reload(); } catch (error) { UIManager.showToast(error.message, 'error'); } }); cancelBtn.addEventListener('click', () => { if (dialog.parentNode) document.body.removeChild(dialog); if (dialog._escHandler) document.removeEventListener('keydown', dialog._escHandler); }); const apiInput = dialog.querySelector('#apiKey'); if (apiInput) apiInput.focus(); }); } catch (err) { console.error('Initialization failed:', err); } } // Run immediately init(); })();