// ==UserScript== // @name OpenList 外网链接增强 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 为 OpenList 右键菜单添加复制外网链接功能 // @author huanfeng // @homepage https://github.com/huanfeng/openlist-external-link // @homepageURL https://github.com/huanfeng/openlist-external-link // @license MIT // @match https://*/* // @match http://*/* // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // @downloadURL https://update.greasyfork.icu/scripts/552014/OpenList%20%E5%A4%96%E7%BD%91%E9%93%BE%E6%8E%A5%E5%A2%9E%E5%BC%BA.user.js // @updateURL https://update.greasyfork.icu/scripts/552014/OpenList%20%E5%A4%96%E7%BD%91%E9%93%BE%E6%8E%A5%E5%A2%9E%E5%BC%BA.meta.js // ==/UserScript== (function () { 'use strict'; // 调试开关 - 设置为 true 开启详细日志 const DEBUG = false; // 日志工具函数 const logger = { log: (...args) => DEBUG && console.log(...args), warn: (...args) => DEBUG && console.warn(...args), error: (...args) => console.error(...args), // 错误日志始终显示 init: (...args) => console.log(...args) // 初始化日志始终显示 }; // 配置管理 class ConfigManager { constructor() { this.configKey = 'openlist_domain_mappings'; this.positionKey = 'openlist_config_button_position'; this.historyKey = 'openlist_link_history'; this.settingsKey = 'openlist_settings'; } // 规范化域名 - 统一处理带/不带/的情况 normalizeDomain(domain) { if (!domain) return ''; domain = domain.trim(); // 移除末尾的 / while (domain.endsWith('/')) { domain = domain.slice(0, -1); } return domain; } // 获取域名映射配置 getDomainMappings() { const config = GM_getValue(this.configKey, '[]'); return JSON.parse(config); } // 保存域名映射配置 saveDomainMappings(mappings) { GM_setValue(this.configKey, JSON.stringify(mappings)); } // 获取配置按钮位置 getButtonPosition() { const position = GM_getValue(this.positionKey, '{"top":80,"right":20,"edge":"right"}'); return JSON.parse(position); } // 保存配置按钮位置 saveButtonPosition(position) { GM_setValue(this.positionKey, JSON.stringify(position)); } // 获取链接历史记录 getLinkHistory() { const history = GM_getValue(this.historyKey, '[]'); return JSON.parse(history); } // 保存链接历史记录 saveLinkHistory(history) { GM_setValue(this.historyKey, JSON.stringify(history)); } // 添加链接到历史记录 addLinkToHistory(url, originalUrl) { const history = this.getLinkHistory(); const newItem = { id: Date.now().toString(), url: url, originalUrl: originalUrl, timestamp: Date.now() }; // 避免重复添加相同的链接 const exists = history.find(item => item.url === url); if (exists) { return; } history.unshift(newItem); // 限制历史记录数量 const maxHistory = this.getSettings().maxHistory || 50; if (history.length > maxHistory) { history.splice(maxHistory); } this.saveLinkHistory(history); } // 删除历史记录 removeLinkFromHistory(id) { const history = this.getLinkHistory(); const filtered = history.filter(item => item.id !== id); this.saveLinkHistory(filtered); } // 清空历史记录 clearLinkHistory() { this.saveLinkHistory([]); } // 获取设置 getSettings() { const settings = GM_getValue(this.settingsKey, '{"maxHistory":50}'); return JSON.parse(settings); } // 保存设置 saveSettings(settings) { GM_setValue(this.settingsKey, JSON.stringify(settings)); } // 检查域名是否已存在 isDomainExists(internalDomain, excludeId = null) { const mappings = this.getDomainMappings(); const normalizedDomain = this.normalizeDomain(internalDomain); return mappings.some(m => { if (excludeId && m.id === excludeId) { return false; // 排除指定ID(用于编辑时) } return this.normalizeDomain(m.internalDomain) === normalizedDomain; }); } // 添加域名映射 addMapping(internalDomain, externalDomain) { const normalizedInternal = this.normalizeDomain(internalDomain); const normalizedExternal = this.normalizeDomain(externalDomain); // 检查是否已存在相同的内网域名 if (this.isDomainExists(normalizedInternal)) { throw new Error('该内网域名已存在,请勿重复添加'); } const mappings = this.getDomainMappings(); const newMapping = { id: Date.now().toString(), internalDomain: normalizedInternal, externalDomain: normalizedExternal, enabled: true }; mappings.push(newMapping); this.saveDomainMappings(mappings); return newMapping; } // 删除域名映射 removeMapping(id) { const mappings = this.getDomainMappings(); const filtered = mappings.filter(m => m.id !== id); this.saveDomainMappings(filtered); } // 更新域名映射 updateMapping(id, internalDomain, externalDomain, enabled = true) { const normalizedInternal = this.normalizeDomain(internalDomain); const normalizedExternal = this.normalizeDomain(externalDomain); // 检查是否与其他映射冲突(排除自身) if (this.isDomainExists(normalizedInternal, id)) { throw new Error('该内网域名已被其他映射使用'); } const mappings = this.getDomainMappings(); const mapping = mappings.find(m => m.id === id); if (mapping) { mapping.internalDomain = normalizedInternal; mapping.externalDomain = normalizedExternal; mapping.enabled = enabled; this.saveDomainMappings(mappings); } } } // URL转换器 class UrlConverter { constructor(configManager) { this.configManager = configManager; } // 将内网URL转换为外网URL convertToExternalUrl(internalUrl) { const mappings = this.configManager.getDomainMappings(); for (const mapping of mappings) { if (mapping.enabled && internalUrl.startsWith(mapping.internalDomain)) { return internalUrl.replace(mapping.internalDomain, mapping.externalDomain); } } return internalUrl; // 如果没有匹配的映射,返回原始URL } // 检查是否有可用的域名映射 hasAvailableMappings() { const mappings = this.configManager.getDomainMappings(); return mappings.some(m => m.enabled); } // 检查当前页面是否配置了域名映射 hasCurrentPageMapping() { const currentOrigin = window.location.origin; const mappings = this.configManager.getDomainMappings(); return mappings.some(m => m.enabled && currentOrigin.startsWith(m.internalDomain)); } } // 菜单增强器 class MenuEnhancer { constructor(configManager, urlConverter) { this.configManager = configManager; this.urlConverter = urlConverter; this.currentFileUrl = ''; this.currentFileElement = null; this.menuObserver = null; } // 初始化 init() { logger.init('[OpenList外网链接] MenuEnhancer 初始化开始'); // 检查是否配置了当前页面的映射 if (this.urlConverter.hasCurrentPageMapping()) { this.setupContextMenuListener(); this.observeMenuChanges(); logger.init('[OpenList外网链接] 右键菜单监听器已设置'); } else { logger.init('[OpenList外网链接] 当前页面未配置域名映射,跳过右键菜单补丁'); } this.addConfigButton(); logger.init('[OpenList外网链接] 配置按钮已添加'); } // 监听右键点击,记录被点击的文件元素 setupContextMenuListener() { document.addEventListener('contextmenu', (e) => { // 查找被右键点击的文件项 const fileItem = e.target.closest('.list-item'); if (fileItem) { this.currentFileElement = fileItem; const href = fileItem.getAttribute('href'); logger.log('[OpenList外网链接] 右键点击文件:', href); } }, true); } // 监听右键菜单的出现 observeMenuChanges() { this.menuObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const contextMenu = node.querySelector('.solid-contextmenu') || (node.classList && node.classList.contains('solid-contextmenu') ? node : null); if (contextMenu) { logger.log('[OpenList外网链接] 检测到右键菜单出现'); this.enhanceContextMenu(contextMenu); } } }); }); }); this.menuObserver.observe(document.body, { childList: true, subtree: true }); logger.log('[OpenList外网链接] MutationObserver 已开始监听 body'); } // 增强右键菜单 enhanceContextMenu(contextMenu) { logger.log('[OpenList外网链接] 开始增强右键菜单'); // 查找"复制链接"菜单项 const copyLinkItem = this.findCopyLinkItem(contextMenu); if (!copyLinkItem) { logger.log('[OpenList外网链接] 未找到"复制链接"菜单项'); return; } logger.log('[OpenList外网链接] 找到"复制链接"菜单项'); // 检查是否已经添加过外网链接菜单项 if (contextMenu.querySelector('.external-link-item')) { logger.log('[OpenList外网链接] 已存在外网链接菜单项,跳过'); return; } // 直接从文件元素获取链接 this.getCurrentFileUrl(); // 创建"复制外网链接"菜单项 const externalLinkItem = this.createExternalLinkMenuItem(); // 在"复制链接"项后插入 copyLinkItem.parentNode.insertBefore(externalLinkItem, copyLinkItem.nextSibling); logger.log('[OpenList外网链接] 已添加"复制外网链接"菜单项'); } // 查找"复制链接"菜单项 findCopyLinkItem(contextMenu) { const items = contextMenu.querySelectorAll('.solid-contextmenu__item'); for (const item of items) { const text = item.querySelector('p'); if (text && text.textContent.trim() === '复制链接') { return item; } } return null; } // 从文件元素直接获取URL getCurrentFileUrl() { logger.log('[OpenList外网链接] 尝试获取文件链接'); if (!this.currentFileElement) { logger.warn('[OpenList外网链接] 没有记录到文件元素'); this.currentFileUrl = window.location.href; return; } // 从文件项的 href 属性获取路径 const href = this.currentFileElement.getAttribute('href'); if (href) { // 构建完整的下载链接 // OpenList 的下载链接格式是: http://domain/d/path const origin = window.location.origin; this.currentFileUrl = `${origin}/d${href}`; logger.log('[OpenList外网链接] 成功获取文件链接:', this.currentFileUrl); } else { logger.warn('[OpenList外网链接] 文件元素没有 href 属性'); this.currentFileUrl = window.location.href; } } // 创建"复制外网链接"菜单项 createExternalLinkMenuItem() { const menuItem = document.createElement('div'); menuItem.className = 'solid-contextmenu__item external-link-item'; const isAvailable = this.urlConverter.hasAvailableMappings(); menuItem.innerHTML = `
`; // 添加点击事件 menuItem.addEventListener('click', (e) => { this.handleExternalLinkCopy(); // 延迟关闭菜单,确保复制操作完成 setTimeout(() => { this.closeContextMenu(); }, 10); }); return menuItem; } // 处理复制外网链接 handleExternalLinkCopy() { if (!this.urlConverter.hasAvailableMappings()) { alert('请先配置域名映射!'); this.showConfigDialog(); return; } logger.log('[OpenList外网链接] 处理复制外网链接, currentFileUrl:', this.currentFileUrl); if (this.currentFileUrl) { const externalUrl = this.urlConverter.convertToExternalUrl(this.currentFileUrl); logger.log('[OpenList外网链接] 转换后的外网URL:', externalUrl); // 使用降级方案复制(因为 clipboard API 可能不可用) this.fallbackCopyToClipboard(externalUrl); // 添加到历史记录 this.configManager.addLinkToHistory(externalUrl, this.currentFileUrl); } else { alert('无法获取文件链接,请重试'); } } // 关闭右键菜单 closeContextMenu() { // 模拟点击事件来关闭菜单 setTimeout(() => { const contextMenu = document.querySelector('.solid-contextmenu'); if (contextMenu) { // 在 body 上触发完整的点击事件序列 const mousedown = new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: 0, clientY: 0 }); document.body.dispatchEvent(mousedown); setTimeout(() => { const mouseup = new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: 0, clientY: 0 }); document.body.dispatchEvent(mouseup); const click = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 0, clientY: 0 }); document.body.dispatchEvent(click); }, 10); } }, 50); } // 降级复制方案 fallbackCopyToClipboard(text) { const textArea = document.createElement('textarea'); textArea.value = text; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); this.showNotification('外网链接已复制到剪贴板'); } // 显示通知 showNotification(message) { const notification = document.createElement('div'); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #4caf50; color: white; padding: 12px 20px; border-radius: 4px; z-index: 10000; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { document.body.removeChild(notification); }, 3000); } // 添加配置按钮 addConfigButton() { logger.log('[OpenList外网链接] 准备添加配置按钮'); const configButton = document.createElement('div'); configButton.id = 'openlist-config-button'; // 从存储中读取位置 const savedPosition = this.configManager.getButtonPosition(); const buttonSize = 40; // 设置初始样式 const updateButtonPosition = (pos) => { configButton.style.cssText = ` position: fixed; width: ${buttonSize}px; height: ${buttonSize}px; background: #2196F3; border-radius: 50%; cursor: move; z-index: 99999; display: flex; align-items: center; justify-content: center; color: white; font-size: 18px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); transition: background 0.3s; user-select: none; `; // 根据贴边位置设置坐标 if (pos.edge === 'left') { configButton.style.left = '20px'; configButton.style.top = pos.top + 'px'; configButton.style.right = 'auto'; } else { configButton.style.right = '20px'; configButton.style.top = pos.top + 'px'; configButton.style.left = 'auto'; } }; updateButtonPosition(savedPosition); configButton.innerHTML = '⚙️'; configButton.title = '配置外网域名映射(可拖动)'; // 拖动功能 let isDragging = false; let startX, startY; let startButtonX, startButtonY; const onMouseDown = (e) => { if (e.button !== 0) return; // 只响应左键 isDragging = true; startX = e.clientX; startY = e.clientY; const rect = configButton.getBoundingClientRect(); startButtonX = rect.left; startButtonY = rect.top; configButton.style.cursor = 'grabbing'; configButton.style.transition = 'none'; // 拖动时禁用过渡 e.preventDefault(); }; const onMouseMove = (e) => { if (!isDragging) return; const deltaX = e.clientX - startX; const deltaY = e.clientY - startY; let newX = startButtonX + deltaX; let newY = startButtonY + deltaY; // 限制在视窗内 newX = Math.max(0, Math.min(newX, window.innerWidth - buttonSize)); newY = Math.max(0, Math.min(newY, window.innerHeight - buttonSize)); // 临时设置位置(不贴边) configButton.style.left = newX + 'px'; configButton.style.top = newY + 'px'; configButton.style.right = 'auto'; e.preventDefault(); }; const onMouseUp = (e) => { if (!isDragging) return; isDragging = false; configButton.style.cursor = 'move'; configButton.style.transition = 'all 0.3s ease'; const rect = configButton.getBoundingClientRect(); const centerX = rect.left + buttonSize / 2; const centerY = rect.top + buttonSize / 2; // 判断贴边 let edge = 'right'; let finalTop = centerY - buttonSize / 2; if (centerX < window.innerWidth / 2) { edge = 'left'; } // 限制top值在合理范围 finalTop = Math.max(20, Math.min(finalTop, window.innerHeight - buttonSize - 20)); // 保存位置 const position = { top: Math.round(finalTop), edge: edge }; this.configManager.saveButtonPosition(position); // 应用贴边位置 updateButtonPosition(position); // 判断是否为点击(移动距离很小) const moveDistance = Math.sqrt( Math.pow(e.clientX - startX, 2) + Math.pow(e.clientY - startY, 2) ); if (moveDistance < 5) { // 视为点击 logger.log('[OpenList外网链接] 配置按钮被点击'); this.showConfigDialog(); } e.preventDefault(); }; configButton.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); configButton.addEventListener('mouseover', () => { if (!isDragging) { configButton.style.background = '#1976D2'; } }); configButton.addEventListener('mouseout', () => { if (!isDragging) { configButton.style.background = '#2196F3'; } }); document.body.appendChild(configButton); logger.log('[OpenList外网链接] 配置按钮已添加到页面'); } // 显示配置对话框 showConfigDialog() { const dialog = new ConfigDialog(this.configManager); dialog.show(); } } // 配置对话框 class ConfigDialog { constructor(configManager) { this.configManager = configManager; this.dialog = null; this.overlay = null; this.currentTab = 'mappings'; // 'mappings' or 'history' } show() { if (this.overlay) { this.overlay.remove(); } this.createDialog(); this.switchTab('mappings'); } createDialog() { // 创建遮罩层 this.overlay = document.createElement('div'); this.overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 10000; display: flex; align-items: center; justify-content: center; `; // 创建对话框 this.dialog = document.createElement('div'); this.dialog.style.cssText = ` background: white; border-radius: 8px; width: 700px; max-width: 90vw; max-height: 80vh; overflow: hidden; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; `; this.dialog.innerHTML = `