// ==UserScript== // @name 移动端去除链接重定向 // @author Deepseek // @description 通用的重定向解析 // @version 1.1 // @namespace Violentmonkey Scripts // @grant GM.xmlHttpRequest // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @match *://*/* // @connect * // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // ---------- 存储键常量 ---------- const DOMAIN_RESOLVER_MAP_KEY = 'domainResolverMap'; // ---------- 工具函数 ---------- function isValidUrl(str) { if (!str || typeof str !== 'string') return false; try { new URL(str); return true; } catch { return false; } } function base64Decode(str) { try { let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); while (base64.length % 4) base64 += '='; return atob(base64); } catch (e) { if (str.startsWith('a1')) { try { return base64Decode(str.slice(2)); } catch {} } throw e; } } function tryDecode(value, types = ['uri', 'base64', 'html']) { for (const type of types) { try { if (type === 'uri') { return decodeURIComponent(value); } else if (type === 'base64') { return base64Decode(value); } else if (type === 'html') { const textarea = document.createElement('textarea'); textarea.innerHTML = value; return textarea.value; } } catch {} } return value; } function extractUrlFromText(text) { if (!text) return null; const urlRegex = /https?:\/\/[^\s"'<>\]\[{}|\\^`]+/gi; const match = text.match(urlRegex); return match ? match[0] : null; } // 清理 URL 中的跟踪参数 const TRACKING_PARAMS = [ 'spm', 'mkt', 'src', 'from', 'source', 'alias', 'vd_source', 'brand', 'curator_clanid', 'snr', 'redir', 'sprefix', 'utm_id', 'utm_content', 'utm_source', 'utm_medium', 'utm_sources', 'utm_term', 'utm_campaign', 'utm_referrer', 'utm_keyword', 'ref', 'feature', 'track', 'trk', 'share_', 'embeds_', 'refer_', '_from', 'scm' ]; const TRACKING_REGEX = /^(spm|from_|ref_|track|trk|share_|embeds_|refer_)|_from$|scm|referrer/i; function cleanUrlParams(url) { try { const urlObj = new URL(url); const params = urlObj.searchParams; TRACKING_PARAMS.forEach(p => params.delete(p)); Array.from(params.keys()).forEach(key => { if (TRACKING_REGEX.test(key)) { params.delete(key); } }); return urlObj.href; } catch { return url; } } // ---------- 配置管理 ---------- const CONFIG = { excludeSameOrigin: false, maxProcessTime: 200, maxResolveDepth: 2, maxConcurrentRequests: 2, requestTimeout: 10000, enableCache: true, enableTextExtract: true, enableHtmlEntityDecode: true, excludedProtocols: ['javascript:', 'mailto:', 'tel:', 'sms:', 'data:', 'about:', 'blob:', 'file:', 'ftp:'] }; try { const userConfig = GM_getValue('redirectConfig', {}); Object.assign(CONFIG, userConfig); } catch (e) { } // ---------- 解析器基类 ---------- class RedirectResolver { constructor(name) { this.name = name || 'BaseResolver'; } canResolve(element) { return false; } async resolve(element) { return null; } isValidUrl(str) { return isValidUrl(str); } } // ---------- 属性解析器 ---------- class AttributeResolver extends RedirectResolver { constructor(extraAttrs = []) { super('AttributeResolver'); this.attributeNames = [ 'data-href', 'data-url', 'data-link', 'data-target', 'data-mdurl', 'rl-link-href', 'mu', 'data-redirect', 'data-dest', 'data-to', 'data-goto', 'data-permalink', 'data-real-url', 'data-original', 'data-ext', 'data-cthref', 'data-original-url', 'data-jump', 'data-lk', 'data-out', 'data-src', 'data-uri', 'data-canonical', 'data-resource', 'data-request-url', ...extraAttrs ]; } canResolve(element) { if (element.hasAttribute(RedirectApp.REDIRECT_COMPLETED)) return false; return this.attributeNames.some(attr => element.hasAttribute(attr)); } resolve(element) { for (const attr of this.attributeNames) { const value = element.getAttribute(attr); if (value && this.isValidUrl(value)) { return value; } } return null; } } // ---------- URL 参数解析器 ---------- class QueryParamResolver extends RedirectResolver { constructor(paramNames, decodeTypes = ['uri', 'base64', 'html']) { super('QueryParamResolver'); this.paramNames = Array.isArray(paramNames) ? paramNames : [paramNames]; this.decodeTypes = decodeTypes; } canResolve(element) { if (element.hasAttribute(RedirectApp.REDIRECT_COMPLETED)) return false; try { const url = new URL(element.href); return this.paramNames.some(param => url.searchParams.has(param)); } catch { return false; } } resolve(element) { try { const url = new URL(element.href); for (const param of this.paramNames) { const rawValue = url.searchParams.get(param); if (!rawValue) continue; const decoded = tryDecode(rawValue, this.decodeTypes); if (this.isValidUrl(decoded)) { return decoded; } } } catch (e) { console.debug(`[${this.name}] 解析失败:`, e); } return null; } } // ---------- 正则解析器 ---------- class RegexResolver extends RedirectResolver { constructor(regex, matchIndex = 1, needDecode = true) { super('RegexResolver'); this.regex = regex; this.matchIndex = matchIndex; this.needDecode = needDecode; } canResolve(element) { if (element.hasAttribute(RedirectApp.REDIRECT_COMPLETED)) return false; return this.regex.test(element.href); } resolve(element) { const match = this.regex.exec(element.href); if (!match || !match[this.matchIndex]) return null; let realUrl = match[this.matchIndex]; if (this.needDecode) { realUrl = tryDecode(realUrl, ['uri', 'base64']); } return this.isValidUrl(realUrl) ? realUrl : null; } } // ---------- 文本内容解析器 ---------- class TextContentResolver extends RedirectResolver { constructor() { super('TextContentResolver'); } canResolve(element) { if (element.hasAttribute(RedirectApp.REDIRECT_COMPLETED)) return false; const text = element.innerText?.trim(); if (!text) return false; return /https?:\/\//i.test(text); } resolve(element) { const text = element.innerText?.trim(); if (!text) return null; const url = extractUrlFromText(text); return url && this.isValidUrl(url) ? url : null; } } // ---------- 高级后备解析器 ---------- class EnhancedFallbackResolver extends RedirectResolver { constructor() { super('EnhancedFallbackResolver'); this.processedUrls = new Map(); this.pendingRequests = new Map(); this.requestQueue = []; this.maxConcurrent = CONFIG.maxConcurrentRequests; } canResolve(element) { return !element.hasAttribute(RedirectApp.REDIRECT_COMPLETED); } async resolve(element) { const href = element.href; if (CONFIG.enableCache && this.processedUrls.has(href)) { return this.processedUrls.get(href); } if (this.pendingRequests.has(href)) { return this.pendingRequests.get(href); } if (this.pendingRequests.size >= this.maxConcurrent) { await new Promise(resolve => this.requestQueue.push(resolve)); } const promise = this.performRequest(href); this.pendingRequests.set(href, promise); try { const result = await promise; if (CONFIG.enableCache) { this.processedUrls.set(href, result); } return result; } finally { this.pendingRequests.delete(href); if (this.requestQueue.length > 0) { const next = this.requestQueue.shift(); next(); } } } async performRequest(href) { let finalUrl = await this._request('HEAD', href); if (finalUrl && finalUrl !== href) return finalUrl; finalUrl = await this._request('GET', href); return finalUrl || null; } _request(method, url) { return new Promise(resolve => { const timeoutId = setTimeout(() => { resolve(null); }, CONFIG.requestTimeout); GM.xmlHttpRequest({ method: method, url: url, anonymous: true, headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, onload: (resp) => { clearTimeout(timeoutId); if (resp.finalUrl && resp.finalUrl !== url && isValidUrl(resp.finalUrl)) { resolve(resp.finalUrl); return; } if (method === 'GET' && resp.responseText) { const metaUrl = this._extractMetaRefresh(resp.responseText); if (metaUrl && isValidUrl(metaUrl)) { resolve(metaUrl); return; } } if (resp.responseHeaders) { const locationMatch = resp.responseHeaders.match(/location:\s*([^\r\n]+)/i); if (locationMatch && locationMatch[1]) { const location = locationMatch[1].trim(); if (isValidUrl(location)) { resolve(location); return; } } } resolve(null); }, onerror: (err) => { clearTimeout(timeoutId); console.debug(`[${this.name}] 请求失败 (${method}):`, err); resolve(null); }, ontimeout: () => { clearTimeout(timeoutId); resolve(null); } }); }); } _extractMetaRefresh(html) { const metaMatch = html.match(/]*http-equiv=["']refresh["'][^>]*content=["']\d*;\s*url=([^"']+)["'][^>]*>/i); return metaMatch ? metaMatch[1].trim() : null; } } // ---------- 智能组合解析器 ---------- class SmartCompositeResolver extends RedirectResolver { constructor(resolvers) { super('SmartCompositeResolver'); this.resolvers = resolvers || []; } addResolver(resolver) { this.resolvers.push(resolver); } canResolve(element) { return this.resolvers.some(r => r.canResolve(element)); } // 根据解析器名称获取解析器实例 getResolverByName(name) { return this.resolvers.find(r => r.name === name); } async resolve(element, preferredResolverName = null) { // 如果指定了首选解析器,先尝试它 if (preferredResolverName) { const preferred = this.getResolverByName(preferredResolverName); if (preferred && preferred.canResolve(element)) { try { const result = await preferred.resolve(element); if (result && this.isValidUrl(result)) { return { url: result, resolverName: preferred.name }; } } catch (e) { console.debug(`[${preferred.name}] 解析异常,回退到完整流程:`, e); } } } // 完整遍历所有解析器 for (const resolver of this.resolvers) { if (resolver.canResolve(element)) { try { const result = await resolver.resolve(element); if (result && this.isValidUrl(result)) { return { url: result, resolverName: resolver.name }; } } catch (e) { console.debug(`[${resolver.name}] 解析异常:`, e); } } } return { url: null, resolverName: null }; } } // ---------- 通用参数列表 ---------- const COMMON_PARAM_NAMES = [ 'url', 'u', 'target', 'to', 'dest', 'href', 'link', 'goto', 'redirect', 'redirect_url', 'redirect_uri', 'return_url', 'return_to', 'next', 'forward', 'fwd', 'out', 'go', 'jump', 'redirectUrl', 'redirectURI', 'gourl', 'q', 'ru', 'path', 'continue', 'return', 'redirect_to', '_url', 'service_url', 'referer' ]; function createDefaultResolver() { const composite = new SmartCompositeResolver(); composite.addResolver(new AttributeResolver()); composite.addResolver(new QueryParamResolver(COMMON_PARAM_NAMES, CONFIG.enableHtmlEntityDecode ? ['uri', 'base64', 'html'] : ['uri', 'base64'] )); composite.addResolver(new RegexResolver( /(?:https?:\/\/[^\/]+)?\/[^?]*[?&](?:url|u|target|to|dest|href|link|goto|redirect)=([^&]+)/i, 1, true )); composite.addResolver(new class extends RedirectResolver { constructor() { super('PathResolver'); } canResolve(element) { try { const path = new URL(element.href).pathname; return /^\/(go|out|redirect|link|to|jump)\//i.test(path); } catch { return false; } } resolve(element) { try { const url = new URL(element.href); const match = url.pathname.match(/^\/(?:go|out|redirect|link|to|jump)\/(.+)/i); if (match && match[1]) { let target = match[1]; if (!target.startsWith('http')) target = 'https://' + target; return this.isValidUrl(target) ? target : null; } } catch {} return null; } }); if (CONFIG.enableTextExtract) { composite.addResolver(new TextContentResolver()); } composite.addResolver(new EnhancedFallbackResolver()); return composite; } // ---------- 主应用类 ---------- class RedirectApp { static REDIRECT_COMPLETED = 'data-redirect-completed'; constructor() { this.config = CONFIG; this.resolver = createDefaultResolver(); this.processingElements = new WeakSet(); this.mutationObserver = null; this.styleObserver = null; // 读取域名-解析器映射缓存 this.domainResolverMap = GM_getValue(DOMAIN_RESOLVER_MAP_KEY, {}); } // 保存缓存到 GM 存储 saveDomainResolverMap() { GM_setValue(DOMAIN_RESOLVER_MAP_KEY, this.domainResolverMap); } shouldProcessLink(element) { const href = element.href; if (!href) return false; const protocol = element.protocol?.toLowerCase(); if (protocol && this.config.excludedProtocols.includes(protocol)) return false; if (href === '#' || href === 'javascript:void(0);' || href === 'about:blank') return false; if (this.config.excludeSameOrigin) { try { const linkOrigin = new URL(href).origin; if (linkOrigin === location.origin) return false; } catch {} } return true; } async processLink(element) { if (this.processingElements.has(element)) return; if (element.hasAttribute(RedirectApp.REDIRECT_COMPLETED)) return; if (!this.shouldProcessLink(element)) return; this.processingElements.add(element); try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('处理超时')), this.config.maxProcessTime) ); // 获取域名 let domain = null; try { domain = new URL(element.href).hostname; } catch { domain = null; } // 检查是否有缓存的解析器 const preferredResolverName = domain ? this.domainResolverMap[domain] : null; // 执行解析 const { url: realUrl, resolverName } = await Promise.race([ this.resolver.resolve(element, preferredResolverName), timeoutPromise ]); if (realUrl && realUrl !== element.href && this.isValidUrl(realUrl)) { // 深度解析(处理多次跳转) const finalUrl = await this.resolveDeep(realUrl); const cleanedUrl = cleanUrlParams(finalUrl); element.href = cleanedUrl; element.setAttribute(RedirectApp.REDIRECT_COMPLETED, 'true'); // 如果解析成功,并且有域名信息,更新缓存 if (domain && resolverName) { // 只有当解析器名称存在且与之前不同时才更新 if (this.domainResolverMap[domain] !== resolverName) { this.domainResolverMap[domain] = resolverName; this.saveDomainResolverMap(); } } } else { // 解析失败,可以清除该域名的缓存(可选) // 这里选择保留,因为可能只是当前链接格式特殊 } } catch (error) { console.debug('处理失败:', error); } finally { this.processingElements.delete(element); } } async resolveDeep(url, depth = 0) { if (depth >= this.config.maxResolveDepth) return url; const a = document.createElement('a'); a.href = url; // 注意:这里不使用缓存,因为深度解析可能跨域,但为了简单,我们直接让resolver完整解析 const { url: newUrl } = await this.resolver.resolve(a, null); if (!newUrl || newUrl === url || !this.isValidUrl(newUrl)) return url; return this.resolveDeep(newUrl, depth + 1); } isValidUrl(str) { return isValidUrl(str); } handleMutation = (mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (this.isLinkElement(node)) { this.processLink(node); } else if (node.querySelectorAll) { node.querySelectorAll(`a:not([${RedirectApp.REDIRECT_COMPLETED}])`) .forEach(aNode => this.processLink(aNode)); } } } } }; isLinkElement(node) { if (node instanceof HTMLAnchorElement) return true; if (node.nodeType === 1 && node.tagName === 'a' && node.namespaceURI === 'http://www.w3.org/2000/svg') { return true; } return false; } start() { document.querySelectorAll('a').forEach(link => this.processLink(link)); this.mutationObserver = new MutationObserver(this.handleMutation); this.mutationObserver.observe(document, { childList: true, subtree: true }); this.styleObserver = new MutationObserver(() => { setTimeout(() => { document.querySelectorAll(`a:not([${RedirectApp.REDIRECT_COMPLETED}])`) .forEach(link => this.processLink(link)); }, 200); }); this.styleObserver.observe(document, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] }); console.log('去除重定向增强缓存版已启动,配置:', this.config); } stop() { if (this.mutationObserver) this.mutationObserver.disconnect(); if (this.styleObserver) this.styleObserver.disconnect(); } } // ---------- 自动跳转处理 ---------- class AutoJumpApp { constructor() { this.commonParams = COMMON_PARAM_NAMES; this.maxDepth = CONFIG.maxResolveDepth; this.cache = new Map(); } async resolveDeep(url, depth = 0) { if (depth >= this.maxDepth) return url; if (CONFIG.enableCache && this.cache.has(url)) return this.cache.get(url); try { const urlObj = new URL(url); for (const param of this.commonParams) { const rawValue = urlObj.searchParams.get(param); if (!rawValue) continue; const decoded = tryDecode(rawValue, ['uri', 'base64', 'html']); if (isValidUrl(decoded)) { const final = await this.resolveDeep(decoded, depth + 1); if (CONFIG.enableCache) this.cache.set(url, final); return final; } } } catch {} if (CONFIG.enableCache) this.cache.set(url, url); return url; } async bootstrap() { try { const currentUrl = location.href; const finalUrl = await this.resolveDeep(currentUrl); if (finalUrl !== currentUrl && isValidUrl(finalUrl)) { const cleaned = cleanUrlParams(finalUrl); location.href = cleaned; return true; } } catch (e) { console.debug('自动跳转失败:', e); } return false; } } // ---------- 拦截 window.open ---------- function rewriteWindowOpen(app) { if (typeof unsafeWindow === 'undefined' || typeof unsafeWindow.open !== 'function') return; const originalOpen = unsafeWindow.open; unsafeWindow.open = function(url, target, features) { if (typeof url === 'string') { (async () => { try { const a = document.createElement('a'); a.href = url; const { url: resolved } = await app.resolver.resolve(a, null); if (resolved && resolved !== url && isValidUrl(resolved)) { url = resolved; } } catch (e) { console.debug('window.open 解析失败:', e); } finally { originalOpen.call(this, url, target, features); } })(); return; } return originalOpen.call(this, url, target, features); }; } // ---------- 面板 UI ---------- const UI_CSS = ` .mask { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0); backdrop-filter:blur(0px); z-index:2147483646; display:flex; align-items:center; justify-content:center; transition: all 0.3s ease; pointer-events: auto; animation: fade-in 0.3s forwards; } .panel { background:#fff; border-radius:24px; box-shadow:0 20px 40px rgba(0,0,0,0.2); padding:24px; display:flex; flex-direction:column; gap:12px; width:90%; max-width:400px; font-family:system-ui,-apple-system,sans-serif; box-sizing:border-box; position:relative; transform: scale(0.9); opacity: 0; animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } .title { margin:0 0 5px 0; font-size:16px; font-weight:700; color:#1a1a1a; text-align:center; } .btn-group { display:grid; grid-template-columns: 1fr 1fr; gap:8px; } button { border:none; border-radius:12px; padding:10px; cursor:pointer; font-size:12px; font-weight:600; transition:all 0.2s; background:#f0f2f5; color:#444; display:flex; align-items:center; justify-content:center; } button:hover { background:#e4e6e9; transform: translateY(-1px); } button:active { transform:scale(0.95); } button.primary { background:#007AFF; color:#fff; } button.primary:hover { background:#0063cc; box-shadow: 0 4px 12px rgba(0,122,255,0.3); } button.danger { background:#ff4d4f; color:#fff; } button.danger:hover { background:#d9363e; box-shadow: 0 4px 12px rgba(255,77,79,0.3); } .list-item { display:flex; justify-content:space-between; align-items:center; padding:5px 0; border-bottom:1px solid #f0f0f0; font-size:12px; } .list-item:last-child { border-bottom:none; } .footer { margin-top:10px; display:flex; gap:8px; } @media (prefers-color-scheme: dark) { .panel { background:#1c1c1e; color:#fff; } .title { color:#fff; } button { background:#2c2c2e; color:#ccc; } button:hover { background:#3a3a3c; } .list-item { border-bottom-color:#333; } } @keyframes fade-in { to { background:rgba(0,0,0,0.3); backdrop-filter:blur(8px); } } @keyframes scale-in { to { transform: scale(1); opacity: 1; } }`; let shadowRoot = null; function ensureShadow() { if (shadowRoot) return; const container = document.createElement('div'); container.id = 'redirect-cache-manager-container'; container.style.cssText = 'position:absolute;top:0;left:0;z-index:2147483647;'; document.documentElement.appendChild(container); shadowRoot = container.attachShadow({ mode: 'closed' }); const style = document.createElement('style'); style.textContent = UI_CSS; shadowRoot.appendChild(style); } function showDomainResolverManager(app) { ensureShadow(); const oldMask = shadowRoot.querySelector('.mask'); if (oldMask) oldMask.remove(); const domainMap = app.domainResolverMap; const domainList = Object.keys(domainMap); const mask = document.createElement('div'); mask.className = 'mask'; mask.innerHTML = `