// ==UserScript== // @name IPFS CID Copy Helper // @namespace http://tampermonkey.net/ // @version 2.6 // @description 自动为网页中的 IPFS 链接和文本添加 CID 复制功能,支持普通文本中的 CID。 // @author cenglin123 // @match *://*/* // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @homepage https://github.com/cenglin123/ipfs-cid-copy-helper // @supportURL https://github.com/cenglin123/ipfs-cid-copy-helper/issues // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; //// 1. 样式及UI初始化 //// // 样式配置 GM_addStyle(` .ipfs-copy-btn-group { display: none; position: absolute; z-index: 10000; transform: translateX(-50%); } .ipfs-copy-btn { display: inline-block; /* 内联块级元素 */ background: #4a90e2; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; margin: 0 2px; /* 添加水平间距 */ box-shadow: 0 2px 5px rgba(0,0,0,0.2); } .ipfs-copy-btn:hover { background: #357abd; } .ipfs-batch-buttons { position: fixed; bottom: 20px; right: 20px; display: none; /* 默认隐藏 */ flex-direction: column; gap: 10px; z-index: 10000; transition: transform 0.3s ease; height: 150px; } .ipfs-batch-buttons.visible { display: flex; /* 显示时改为 flex */ } .ipfs-batch-buttons.collapsed { transform: translateX(calc(100% + 20px)); } .ipfs-batch-btn { background: #4a90e2; color: white; padding: 8px 15px; border-radius: 4px; font-size: 14px; cursor: pointer; box-shadow: 0 2px 5px rgba(0,0,0,0.2); display: none; position: relative; white-space: nowrap; transition: transform 0.3s ease; } .ipfs-batch-btn:hover { background: #357abd; } .ipfs-copy-count { background: #5cb3ff; color: white; border-radius: 50%; padding: 2px 6px; font-size: 12px; position: absolute; top: -8px; right: -8px; } .ipfs-toggle-btn { position: absolute; left: -28px; top: 0; width: 28px; height: 28px; background: #4a90e2; color: white; border: none; border-radius: 4px 0 0 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: -2px 0 5px rgba(0,0,0,0.2); } .ipfs-toggle-btn:hover { background: #357abd; } .ipfs-toggle-btn svg { width: 16px; height: 16px; transition: transform 0.3s ease; transform: rotate(180deg); } .collapsed .ipfs-toggle-btn svg { transform: rotate(0deg); } .ipfs-config-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 10001; max-width: 600px; width: 90%; max-height: 80vh; display: none; flex-direction: column; gap: 10px; } .ipfs-config-panel.visible { display: flex; } .ipfs-config-panel textarea { width: 100%; height: 200px; margin: 10px 0; padding: 8px; border: 1px solid #ddd; border-radius: 4px; resize: vertical; } .ipfs-config-panel .button-group { display: flex; gap: 10px; justify-content: flex-end; } .ipfs-config-panel button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; background: #4a90e2; color: white; } .ipfs-config-panel button:hover { background: #357abd; } .ipfs-config-panel h2 { margin: 0 0 10px 0; padding-bottom: 10px; border-bottom: 1px solid #ddd; } .ipfs-config-panel .help-text { font-size: 12px; color: #666; margin-bottom: 10px; } `); // 创建悬停按钮 UI const copyBtnGroup = document.createElement('div'); copyBtnGroup.className = 'ipfs-copy-btn-group'; document.body.appendChild(copyBtnGroup); const copyBtn = document.createElement('div'); copyBtn.className = 'ipfs-copy-btn'; copyBtnGroup.appendChild(copyBtn); const copyLinkBtn = document.createElement('div'); copyLinkBtn.className = 'ipfs-copy-btn'; copyBtnGroup.appendChild(copyLinkBtn); // 创建右下角批量复制浮窗 UI const batchButtonsContainer = document.createElement('div'); batchButtonsContainer.className = 'ipfs-batch-buttons'; if (localStorage.getItem('ipfsCopyHelperDefaultCollapsed') === 'true') { // 根据默认设置决定是否添加 collapsed 类 batchButtonsContainer.classList.add('collapsed'); } document.body.appendChild(batchButtonsContainer); const toggleBtn = document.createElement('button'); toggleBtn.className = 'ipfs-toggle-btn'; toggleBtn.innerHTML = ` `; batchButtonsContainer.appendChild(toggleBtn); const batchCopyBtn = document.createElement('div'); batchCopyBtn.className = 'ipfs-batch-btn'; batchCopyBtn.innerHTML = '批量复制 CID 0'; batchButtonsContainer.appendChild(batchCopyBtn); const batchFilenameBtn = document.createElement('div'); batchFilenameBtn.className = 'ipfs-batch-btn'; batchFilenameBtn.innerHTML = '批量复制文件名 0'; batchButtonsContainer.appendChild(batchFilenameBtn); const batchDownloadBtn = document.createElement('div'); batchDownloadBtn.className = 'ipfs-batch-btn'; batchDownloadBtn.innerHTML = '批量复制下载链接 0'; batchButtonsContainer.appendChild(batchDownloadBtn); //// 2. 提取CID及页面扫描 //// // CID 正则表达式模式 const CID_PATTERNS = [ /\b(baf[yk][a-zA-Z0-9]{55})\b/i, // IPFS CID v1 /\b(Qm[a-zA-Z0-9]{44})\b/i, // IPFS CID v0 /\b(k51[a-zA-Z0-9]{59})\b/i // IPNS Key ]; function extractCID(input) { try { // 定义要排除的 CID const excludedCIDs = [ 'bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354', 'QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn' ]; let extractedCID = null; // 如果输入看起来像是URL,进行URL解析 if (input.includes('://') || input.startsWith('//')) { const urlObj = new URL(input); // 匹配子域名形式 const subdomain = urlObj.hostname.split('.')[0]; if (subdomain.match(/^(baf[yk][a-zA-Z0-9]{55}|Qm[a-zA-Z0-9]{44})$/i)) { extractedCID = subdomain; } // 匹配 IPNS key if (subdomain.match(/^k51[a-zA-Z0-9]{59}$/i)) { extractedCID = subdomain; } // 匹配路径形式 const ipfsMatch = urlObj.pathname.match(/\/ipfs\/(baf[yk][a-zA-Z0-9]{55}|Qm[a-zA-Z0-9]{44})/i); if (ipfsMatch) { extractedCID = ipfsMatch[1]; } // 匹配 IPNS 路径 const ipnsMatch = urlObj.pathname.match(/\/ipns\/(k51[a-zA-Z0-9]{59})/i); if (ipnsMatch) { extractedCID = ipnsMatch[1]; } } else { // 直接尝试匹配CID格式 const cidMatch = input.match(/^(baf[yk][a-zA-Z0-9]{55}|Qm[a-zA-Z0-9]{44}|k51[a-zA-Z0-9]{59})$/i); if (cidMatch) { extractedCID = cidMatch[1]; } } // 检查是否为排除的 CID if (extractedCID && excludedCIDs.includes(extractedCID.toLowerCase())) { return null; } return extractedCID; } catch (e) { console.error('CID提取错误:', e); return null; } } // 判断是否为IPFS文件夹浏览页面 function isIPFSBrowsingPage(url) { try { const urlObj = new URL(url); // 检查子域名形式(支持 .ipfs. 和 .eth. 形式) const hostParts = urlObj.hostname.split('.'); if (hostParts.length >= 3) { const possibleCid = hostParts[0]; // 如果子域名是有效的 CID if (extractCID(possibleCid)) { // 检查是否包含 .ipfs. 或 .eth. const isIpfsDomain = hostParts.some((part, index) => index < hostParts.length - 1 && (part === 'ipfs' || part === 'eth') ); // 如果是子域名形式,且路径长度大于1(不只是根路径),则认为是浏览页面 if (isIpfsDomain && urlObj.pathname.length > 1) { return true; } } } // 检查路径形式 if (!urlObj.pathname.includes('/ipfs/')) { return false; } // 对于 Web UI 的特殊处理 if (urlObj.hash && urlObj.hash.includes('/files')) { return false; } // 普通文件浏览页面检查 const parts = urlObj.pathname.split('/'); return parts.length > 3; } catch (e) { console.error('URL解析错误:', e); return false; } } // 扫描页面中纯文本节点的函数 function scanTextNodes(node) { // 首先检查是否在排除列表中 if (isExcludedPage()) { return; } if (node.nodeType === Node.TEXT_NODE) { let hasMatch = false; let matches = []; // 收集所有匹配 for (const pattern of CID_PATTERNS) { const patternMatches = [...node.textContent.matchAll(new RegExp(pattern, 'g'))]; if (patternMatches.length > 0) { matches = matches.concat(patternMatches); hasMatch = true; } } if (hasMatch) { const container = document.createElement('span'); container.style.position = 'relative'; container.textContent = node.textContent; // 将匹配的 CID 添加到 linkInfo matches.forEach(match => { const cid = match[0]; const type = cid.startsWith('k51') ? 'IPNS Key' : 'IPFS CID'; // 只有当这个 CID 还不存在时才添加 if (!linkInfo.has(cid)) { linkInfo.set(cid, { type: type, url: null, text: cid, filename: null, isLink: false // 标记这是文本 }); } // 为每个CID创建一个内部span const cidSpan = document.createElement('span'); cidSpan.style.position = 'relative'; cidSpan.textContent = cid; cidSpan.dataset.cid = cid; cidSpan.dataset.type = type; // 替换原文本中的CID const textBefore = container.textContent.substring(0, match.index); const textAfter = container.textContent.substring(match.index + cid.length); container.textContent = ''; if (textBefore) container.appendChild(document.createTextNode(textBefore)); container.appendChild(cidSpan); if (textAfter) container.appendChild(document.createTextNode(textAfter)); }); // 添加鼠标事件监听器 container.addEventListener('mouseover', function(e) { const target = e.target; if (target.dataset && target.dataset.cid) { const rect = target.getBoundingClientRect(); showCopyButton( rect.left + (rect.width / 2), rect.bottom, target.dataset.cid, target.dataset.type ); } }); node.parentNode.replaceChild(container, node); } } else if (node.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE', 'TEXTAREA', 'A'].includes(node.tagName)) { Array.from(node.childNodes).forEach(scanTextNodes); } } // 扫描页面超链接的函数 function scanPageForLinks() { // 首先检查是否在排除列表中 if (isExcludedPage()) { console.log('当前页面在排除列表中,停止扫描'); // 清理已有的批量按钮 batchButtonsContainer.classList.remove('visible'); return; } linkInfo.clear(); // 先扫描文本节点,确保文本 CID 在后面 scanTextNodes(document.body); // 再扫描链接 const currentPageCID = extractCID(window.location.href); const currentPageBase = window.location.origin + window.location.pathname.split('/').slice(0, -1).join('/'); const links = document.getElementsByTagName('a'); for (const link of links) { const cid = extractCID(link.href); if (!cid) continue; // 只过滤无效的 CID,不再过滤当前页面的 CID try { const linkUrl = new URL(link.href); const linkBase = linkUrl.origin + linkUrl.pathname.split('/').slice(0, -1).join('/'); if (linkBase === currentPageBase) continue; const existingInfo = linkInfo.get(cid); const filename = extractFilename(link.href, link.textContent); linkInfo.set(cid, { type: detectLinkType(link.href), url: link.href, text: link.textContent.trim(), filename: filename, isLink: true }); } catch (e) { console.error('URL解析错误:', e); } } updateBatchButtons(); } // 提取文件名的函数 function extractFilename(url, linkText) { const filenameParam = new URL(url).searchParams.get('filename'); if (filenameParam) { return decodeURIComponent(filenameParam); } const pathParts = new URL(url).pathname.split('/'); const lastPart = pathParts[pathParts.length - 1]; if (lastPart && !lastPart.match(/^(Qm[a-zA-Z0-9]{44}|baf[a-zA-Z0-9]+|k51[a-zA-Z0-9]+)$/i)) { return decodeURIComponent(lastPart); } if (linkText && linkText.trim() && !linkText.includes('...')) { return linkText.trim(); } return null; } // 探测超链接IPFS类型的函数 function detectLinkType(url) { try { const urlObj = new URL(url); const subdomain = urlObj.hostname.split('.')[0]; if (subdomain.match(/^(k51[a-zA-Z0-9]{1,})$/i)) { return 'IPNS Key'; } if (subdomain.match(/^(baf[a-zA-Z0-9]{1,}|Qm[a-zA-Z0-9]{44})$/i)) { return 'IPFS CID'; } if (url.includes('/ipns/') || url.match(/k51[a-zA-Z0-9]{1,}/i)) { return 'IPNS Key'; } return 'IPFS CID'; } catch (e) { console.error('URL解析错误:', e); return 'IPFS CID'; } } // 复制某个内容到剪贴板的函数 function copyToClipboard(text, button) { const originalText = button.textContent; // 创建后备复制方案 function fallbackCopy() { try { const textArea = document.createElement('textarea'); textArea.value = text; // 将文本区域放在不可见位置 textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; textArea.style.top = '-9999px'; document.body.appendChild(textArea); // 选择并复制文本 textArea.select(); const success = document.execCommand('copy'); document.body.removeChild(textArea); if (success) { button.textContent = '已复制!'; } else { button.textContent = '复制失败'; } } catch (err) { console.error('复制失败:', err); button.textContent = '复制失败'; } setTimeout(() => { button.textContent = originalText; }, 1000); } // 优先使用 Clipboard API,如果不支持则使用后备方案 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { navigator.clipboard.writeText(text) .then(() => { button.textContent = '已复制!'; setTimeout(() => { button.textContent = originalText; }, 1000); }) .catch(err => { console.warn('Clipboard API 失败,使用后备方案:', err); fallbackCopy(); }); } else { console.warn('Clipboard API 不可用,使用后备方案'); fallbackCopy(); } } // 批量复制函数 function batchCopyItems(type, button) { const validEntries = getValidEntries(); const totalCount = validEntries.length; if (totalCount === 0) { button.textContent = '没有可用的项目'; setTimeout(() => { button.innerHTML = `批量复制${type === 'cid' ? 'CID' : type === 'filename' ? '文件名' : '下载链接'} ${totalCount}`; }, 1000); return; } const items = validEntries.map(([cid, info]) => { let filenameFromText = null; if (!info.isLink && info.text) { const cidIndex = info.text.indexOf(cid); if (cidIndex !== -1) { const afterCid = info.text.slice(cidIndex + cid.length); const filenameMatch = afterCid.match(/[?&]filename=([^&\n\r]+)/); if (filenameMatch) { try { filenameFromText = decodeURIComponent(filenameMatch[1]); } catch (e) { console.error('文件名解码错误:', e); } } } } switch (type) { case 'cid': return cid; case 'filename': if (info.filename && !info.filename.includes('/ipfs/')) { return info.filename; } if (filenameFromText) { return filenameFromText; } return cid; case 'url': if (info.isLink && info.url) { let url = info.url; const validFilename = info.filename && !info.filename.includes('/ipfs/') ? info.filename : (filenameFromText || cid); if (!url.includes('?filename=')) { url += (url.includes('?') ? '&' : '?') + 'filename=' + encodeURIComponent(validFilename); } return url; } const gateway = getGateway(); const filename = filenameFromText || cid; return `${gateway}/ipfs/${cid}?filename=${encodeURIComponent(filename)}`; default: return cid; } }); const formattedItems = items.join('\n'); copyToClipboard(formattedItems, button); } function getValidEntries() { // 定义要排除的 CID 列表 const excludedCIDs = [ 'bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354', 'QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn' ]; const currentPageCID = extractCID(window.location.href); // 获取并过滤条目 return Array.from(linkInfo.entries()) .filter(([cid]) => { // 排除当前页面的 CID if (cid === currentPageCID) return false; // 排除空文件夹 CID(根据CID版本选择比较方式) return !excludedCIDs.some(excluded => { // CID v0 (Qm开头) - 大小写敏感 if (excluded.startsWith('Qm')) { return excluded === cid; } // CID v1 (baf开头) - 大小写不敏感 return excluded.toLowerCase() === cid.toLowerCase(); }); }); } // 更新显示计数的函数 function updateBatchButtons() { const validEntries = getValidEntries(); const totalCount = validEntries.length; if (totalCount > 0) { batchButtonsContainer.classList.add('visible'); [batchCopyBtn, batchDownloadBtn, batchFilenameBtn].forEach(btn => { btn.style.display = 'block'; btn.innerHTML = `${btn.innerHTML.split('<')[0]}${totalCount}`; }); } else { batchButtonsContainer.classList.remove('visible'); [batchCopyBtn, batchDownloadBtn, batchFilenameBtn].forEach(btn => { btn.style.display = 'none'; }); } } // 调用统一的复制处理函数 function batchCopyCIDs() { batchCopyItems('cid', batchCopyBtn); } function batchCopyFilenames() { batchCopyItems('filename', batchFilenameBtn); } function batchCopyDownloadLinks() { batchCopyItems('url', batchDownloadBtn); } // 显示复制按钮 function showCopyButton(x, y, cid, type, isLink = false) { // 如果在排除列表中,不显示按钮 if (isExcludedPage()) { return; } // 清除可能存在的定时器 if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } if (showTimeout) { clearTimeout(showTimeout); showTimeout = null; } copyBtnGroup.style.display = 'block'; copyBtnGroup.style.top = `${y + window.scrollY + 5}px`; copyBtnGroup.style.left = `${x + window.scrollX}px`; copyBtn.textContent = `复制 ${type}`; copyBtn.onclick = () => { copyToClipboard(cid, copyBtn); }; // 只为纯文本CID显示下载链接按钮 if (!isLink) { copyLinkBtn.style.display = 'inline-block'; copyLinkBtn.textContent = '复制下载链接'; copyLinkBtn.onclick = () => { const gateway = getGateway(); const downloadLink = `${gateway}/ipfs/${cid}`; copyToClipboard(downloadLink, copyLinkBtn); }; } else { copyLinkBtn.style.display = 'none'; } } // 修改隐藏按钮函数 function hideButton() { // 如果在排除列表中,确保按钮隐藏 if (isExcludedPage()) { copyBtnGroup.style.display = 'none'; return; } if (hideTimeout) { clearTimeout(hideTimeout); } if (showTimeout) { clearTimeout(showTimeout); } hideTimeout = setTimeout(() => { if (!isButtonHovered && !currentHoveredElement) { copyBtnGroup.style.display = 'none'; } }, 150); } let scanTimeout; function initPageScan() { if (isExcludedPage()) { console.log('当前页面在排除列表中,不初始化扫描'); // 确保批量按钮被隐藏 batchButtonsContainer.classList.remove('visible'); return; } if (scanTimeout) { clearTimeout(scanTimeout); } scanTimeout = setTimeout(scanPageForLinks, 1000); } //// 3. 鼠标悬停事件处理 //// // 初始化状态变量 let currentHoveredElement = null; let currentHoveredLink = null; // 添加这个变量声明 let isButtonHovered = false; let hideTimeout = null; let showTimeout = null; // 统一处理文本和链接的悬停 function handleElementHover(element, cid, type, isLink = false) { // 首先检查是否在排除列表中 if (isExcludedPage()) { return; } if (currentHoveredElement === element) return; // 如果是同一个元素,不重复处理 currentHoveredElement = element; const rect = element.getBoundingClientRect(); // 使用延时显示,避免快速划过时的闪烁 if (showTimeout) { clearTimeout(showTimeout); } showTimeout = setTimeout(() => { showCopyButton( rect.left + (rect.width / 2), rect.bottom, cid, type, isLink ); }, 50); } // mouseover 事件处理 document.addEventListener('mouseover', function(e) { // 如果在排除列表中,直接返回 if (isExcludedPage()) { return; } // 处理链接 const link = e.target.closest('a'); if (link) { const href = link.href; if (!href) return; const linkCID = extractCID(href); if (!linkCID) return; const shouldShow = isIPFSBrowsingPage(window.location.href) || linkCID !== extractCID(window.location.href); if (shouldShow) { handleElementHover(link, linkCID, detectLinkType(href), true); // 添加 isLink 参数 } return; } // 处理文本节点中的 CID const cidSpan = e.target.closest('[data-cid]'); if (cidSpan && cidSpan.dataset.cid) { handleElementHover(cidSpan, cidSpan.dataset.cid, cidSpan.dataset.type, false); // 添加 isLink 参数 return; } }); // mousemove 事件处理 document.addEventListener('mousemove', function(e) { const overLink = e.target.closest('a'); const overButton = e.target.closest('.ipfs-copy-btn-group'); if (!overLink && !overButton) { currentHoveredLink = null; isButtonHovered = false; hideButton(); } }); // mouseout 事件处理 document.addEventListener('mouseout', function(e) { const relatedTarget = e.relatedTarget; if (!relatedTarget || (!relatedTarget.closest('.ipfs-copy-btn-group') && !relatedTarget.dataset?.cid && !relatedTarget.closest('a'))) { currentHoveredElement = null; currentHoveredLink = null; hideButton(); } }); // 按钮组的事件监听 copyBtnGroup.addEventListener('mouseover', function() { if (isExcludedPage()) { return; } isButtonHovered = true; if (hideTimeout) { clearTimeout(hideTimeout); } if (showTimeout) { clearTimeout(showTimeout); } }); copyBtnGroup.addEventListener('mouseout', function(e) { if (isExcludedPage()) { return; } isButtonHovered = false; const relatedTarget = e.relatedTarget; if (!relatedTarget || (!relatedTarget.dataset?.cid && !relatedTarget.closest('a') && !relatedTarget.closest('.ipfs-copy-btn-group'))) { hideButton(); } }); // observer 的设置,观察DOM变化 let scanScheduled = false; const observer = new MutationObserver(() => { if (!scanScheduled) { scanScheduled = true; setTimeout(() => { initPageScan(); scanScheduled = false; }, 1500); // 在1500ms后再执行扫描,避免频繁触发 } }); // DOM 初始化代码 document.addEventListener('DOMContentLoaded', () => { if (!isExcludedPage()) { observer.observe(document.body, { childList: true, subtree: true }); initPageScan(); } }); observer.observe(document.body, { childList: true, subtree: true }); //// 4. 添加油猴菜单命令 //// GM_registerMenuCommand('切换右下角浮窗默认展开/收起状态', () => { const defaultCollapsed = localStorage.getItem('ipfsCopyHelperDefaultCollapsed'); const newDefault = defaultCollapsed === 'true' ? 'false' : 'true'; localStorage.setItem('ipfsCopyHelperDefaultCollapsed', newDefault); alert(`默认状态已更改为:${newDefault === 'true' ? '收起' : '展开'}`); }); // 创建排除网址的配置面板 const configPanel = document.createElement('div'); configPanel.className = 'ipfs-config-panel'; configPanel.innerHTML = `