// ==UserScript== // @name L站划词Base64解码工具 // @namespace https://tampermonkey.net/ // @version 1.2.0 // @description 划词后自动删除汉字、中文标点和Emoji,尝试Base64解码后,再次尝试清理并自动复制 // @author ChatGPT // @match https://linux.do/* // @grant GM_setClipboard // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; const CONFIG = { // 划词后显示的悬浮按钮文字 buttonText: '解码B64', // 处理成功后自动复制 autoCopy: true, // 默认删除中文标点 removeChinesePunctuation: true, // 默认删除 Emoji 表情 removeEmoji: true, // 清理删除字符后产生的多余空格 cleanExtraSpaces: true, // Base64 最小长度,避免过短字符串被误判 minBase64Length: 4, // 快捷键:Alt + Shift + B shortcutKey: 'b' }; let hanRegex; let extendedPictographicRegex; let regionalIndicatorRegex; let emojiModifierRegex; try { // Unicode 汉字,包括扩展区 hanRegex = new RegExp('\\p{Script=Han}', 'gu'); // 大部分 Emoji 图形 extendedPictographicRegex = new RegExp( '\\p{Extended_Pictographic}', 'gu' ); // 国旗 Emoji 的区域指示符 regionalIndicatorRegex = new RegExp( '\\p{Regional_Indicator}', 'gu' ); // Emoji 肤色修饰符 emojiModifierRegex = new RegExp( '\\p{Emoji_Modifier}', 'gu' ); } catch (error) { // 旧浏览器兼容 hanRegex = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g; extendedPictographicRegex = /(?:[\uD83C-\uDBFF][\uDC00-\uDFFF]|[\u2600-\u27BF])/g; regionalIndicatorRegex = /[\uD83C][\uDDE6-\uDDFF]/g; emojiModifierRegex = /[\uD83C][\uDFFB-\uDFFF]/g; } let selectionSnapshot = null; let lastMousePosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; let toastTimer = null; /* * 页面样式 */ const style = document.createElement('style'); style.textContent = ` #tm-base64-clean-button { position: fixed; z-index: 2147483646; display: none; padding: 6px 11px; border: 1px solid #000000; border-radius: 6px; background: #000000; color: #ffffff; font-size: 12px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.4; cursor: pointer; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.28); user-select: none; white-space: nowrap; } #tm-base64-clean-button:hover { border-color: #333333; background: #333333; } #tm-base64-clean-button:active { border-color: #555555; background: #555555; transform: translateY(1px); } #tm-base64-clean-result { position: fixed; z-index: 2147483647; display: none; width: min(520px, calc(100vw - 32px)); padding: 14px; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 10px; background: #ffffff; color: #222222; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; box-sizing: border-box; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.24); } #tm-base64-clean-title { margin-bottom: 10px; font-size: 14px; font-weight: 600; } #tm-base64-clean-text { display: block; width: 100%; min-height: 110px; max-height: 45vh; padding: 10px; border: 1px solid #d8d8d8; border-radius: 6px; background: #fafafa; color: #222222; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.5; resize: vertical; box-sizing: border-box; } #tm-base64-clean-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } #tm-base64-clean-actions button { padding: 6px 12px; border: 1px solid #d0d0d0; border-radius: 6px; background: #ffffff; color: #222222; font-size: 13px; cursor: pointer; } #tm-base64-clean-actions button:hover { background: #f2f2f2; } #tm-base64-clean-copy { border-color: #000000 !important; background: #000000 !important; color: #ffffff !important; } #tm-base64-clean-copy:hover { border-color: #333333 !important; background: #333333 !important; } #tm-base64-clean-toast { position: fixed; left: 50%; bottom: 40px; z-index: 2147483647; display: none; padding: 8px 14px; border-radius: 7px; background: rgba(20, 20, 20, 0.92); color: #ffffff; font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; transform: translateX(-50%); pointer-events: none; white-space: nowrap; } `; document.documentElement.appendChild(style); /* * 创建悬浮按钮 */ const actionButton = document.createElement('button'); actionButton.id = 'tm-base64-clean-button'; actionButton.type = 'button'; actionButton.textContent = CONFIG.buttonText; actionButton.title = '清理文字并尝试 Base64 解码'; /* * 创建结果面板 */ const resultPanel = document.createElement('div'); resultPanel.id = 'tm-base64-clean-result'; resultPanel.innerHTML = `
`; /* * 创建底部提示 */ const toast = document.createElement('div'); toast.id = 'tm-base64-clean-toast'; document.documentElement.appendChild(actionButton); document.documentElement.appendChild(resultPanel); document.documentElement.appendChild(toast); const resultTitle = resultPanel.querySelector( '#tm-base64-clean-title' ); const resultText = resultPanel.querySelector( '#tm-base64-clean-text' ); const copyButton = resultPanel.querySelector( '#tm-base64-clean-copy' ); const closeButton = resultPanel.querySelector( '#tm-base64-clean-close' ); /** * 删除 Emoji 表情。 */ function removeEmojiCharacters(text) { let result = String(text); if (!CONFIG.removeEmoji) { return result; } // 删除数字键帽表情,例如 1️⃣、#️⃣、*️⃣ result = result.replace( /[0-9#*]\uFE0F?\u20E3/g, '' ); // 删除普通 Emoji 图形 result = result.replace( extendedPictographicRegex, '' ); // 删除国旗区域指示符 result = result.replace( regionalIndicatorRegex, '' ); // 删除肤色修饰符 result = result.replace( emojiModifierRegex, '' ); // 删除变体选择符 result = result.replace( /[\uFE0E\uFE0F]/g, '' ); // 删除 Emoji 组合序列中的零宽连接符 result = result.replace( /\u200D/g, '' ); // 删除残留的组合键帽字符 result = result.replace( /\u20E3/g, '' ); // 删除部分标签序列字符 result = result.replace( /[\u{E0020}-\u{E007F}]/gu, '' ); return result; } /** * 删除汉字、中文标点和 Emoji。 */ function cleanText(text) { let result = String(text); // 删除汉字 result = result.replace( hanRegex, '' ); // 删除 Emoji result = removeEmojiCharacters(result); if (CONFIG.removeChinesePunctuation) { /* * 删除中文标点和常见全角标点。 * * 不删除英文的 +、/、=, * 因为这些是标准 Base64 可能使用的字符。 */ result = result.replace( /[,。!?;:、、“”‘’()【】《》〈〉〔〕[]{}…—·~﹏「」『』〖〗〘〙〚〛﹁﹂﹃﹄﹐﹑﹒﹔﹕﹖﹗]/g, '' ); // 删除全角空格 result = result.replace( /\u3000/g, '' ); } if (CONFIG.cleanExtraSpaces) { result = result .replace(/[ \t]{2,}/g, ' ') .replace(/[ \t]+\n/g, '\n') .replace(/\n[ \t]+/g, '\n') .trim(); } return result; } /** * 判断元素是否为可选择文字的输入框。 */ function isSelectableInput(element) { if (!element) { return false; } if (element instanceof HTMLTextAreaElement) { return true; } if (!(element instanceof HTMLInputElement)) { return false; } const supportedTypes = [ 'text', 'search', 'url', 'tel', 'email' ]; return supportedTypes.includes(element.type); } /** * 获取当前选中的文字。 * * 支持: * 1. 普通网页文字 * 2. input 输入框 * 3. textarea 文本框 */ function getSelectionSnapshot() { const activeElement = document.activeElement; if ( isSelectableInput(activeElement) && typeof activeElement.selectionStart === 'number' && typeof activeElement.selectionEnd === 'number' && activeElement.selectionEnd > activeElement.selectionStart ) { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; return { text: activeElement.value.slice(start, end), rect: null, source: 'input' }; } const selection = window.getSelection(); if ( !selection || selection.rangeCount === 0 || selection.isCollapsed ) { return null; } const text = selection.toString(); if (!text) { return null; } const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); return { text, rect, source: 'page' }; } /** * 尝试将字符串作为 Base64 解码为 UTF-8 文本。 * * 返回 null 表示不是合法的文本型 Base64。 */ function tryDecodeBase64(value) { /* * Base64 中可能包含换行和空格, * 解码前删除全部空白字符。 */ let compact = String(value).replace( /\s+/g, '' ); if ( !compact || compact.length < CONFIG.minBase64Length ) { return null; } /* * 支持 URL-safe Base64: * * - 转为 + * _ 转为 / */ compact = compact .replace(/-/g, '+') .replace(/_/g, '/'); /* * Base64 只能包含字母、数字、+、/、=。 * 等号最多两个,并且只能位于末尾。 */ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) { return null; } const unpadded = compact.replace( /=+$/, '' ); /* * 长度除以 4 余 1 时,不可能是合法 Base64。 */ if (unpadded.length % 4 === 1) { return null; } /* * 补齐末尾等号。 */ const padded = unpadded + '='.repeat( (4 - (unpadded.length % 4)) % 4 ); let binary; try { binary = atob(padded); } catch (error) { return null; } /* * 将解码后的内容重新编码并比较, * 避免 atob 宽松解析导致误判。 */ try { const encodedAgain = btoa(binary).replace( /=+$/, '' ); if (encodedAgain !== unpadded) { return null; } } catch (error) { return null; } const bytes = new Uint8Array(binary.length); for ( let index = 0; index < binary.length; index += 1 ) { bytes[index] = binary.charCodeAt(index); } let decoded; try { /* * fatal: true: * 遇到无效 UTF-8 时直接失败, * 避免将二进制数据显示成乱码。 */ decoded = new TextDecoder( 'utf-8', { fatal: true } ).decode(bytes); } catch (error) { return null; } // 删除 UTF-8 BOM decoded = decoded.replace( /^\uFEFF/, '' ); /* * 如果结果包含明显的二进制控制字符, * 则不视为正常文本。 * * 保留制表符、换行和回车。 */ const invalidControlCharacters = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/; if (invalidControlCharacters.test(decoded)) { return null; } return decoded; } /** * 完整处理流程: * * 1. 清理原始选中文字 * 2. 尝试 Base64 解码 * 3. 解码成功后再次清理解码结果 * 4. 解码失败则返回第一次清理后的结果 */ function processSelectedText(rawText) { const cleanedBeforeDecode = cleanText(rawText); const decodeCandidate = cleanedBeforeDecode.trim(); const decoded = tryDecodeBase64( decodeCandidate ); if (decoded !== null) { return { result: cleanText(decoded), decoded: true, cleanedBeforeDecode }; } return { result: cleanedBeforeDecode, decoded: false, cleanedBeforeDecode }; } /** * 设置悬浮按钮位置。 */ function positionActionButton( snapshot, fallbackX, fallbackY ) { actionButton.style.display = 'block'; let left; let top; if ( snapshot.rect && ( snapshot.rect.width > 0 || snapshot.rect.height > 0 ) ) { left = snapshot.rect.right + 8; top = snapshot.rect.bottom + 8; } else { left = fallbackX + 8; top = fallbackY + 8; } const buttonWidth = actionButton.offsetWidth; const buttonHeight = actionButton.offsetHeight; left = Math.max( 8, Math.min( left, window.innerWidth - buttonWidth - 8 ) ); top = Math.max( 8, Math.min( top, window.innerHeight - buttonHeight - 8 ) ); actionButton.style.left = `${left}px`; actionButton.style.top = `${top}px`; } /** * 设置结果面板位置。 */ function positionResultPanel() { resultPanel.style.display = 'block'; const panelWidth = resultPanel.offsetWidth; const panelHeight = resultPanel.offsetHeight; let left = parseFloat(actionButton.style.left) || 16; let top = ( parseFloat(actionButton.style.top) || 16 ) + actionButton.offsetHeight + 8; left = Math.max( 16, Math.min( left, window.innerWidth - panelWidth - 16 ) ); top = Math.max( 16, Math.min( top, window.innerHeight - panelHeight - 16 ) ); resultPanel.style.left = `${left}px`; resultPanel.style.top = `${top}px`; } function hideActionButton() { actionButton.style.display = 'none'; } function hideResultPanel() { resultPanel.style.display = 'none'; } /** * 显示底部提示。 */ function showToast(message) { toast.textContent = message; toast.style.display = 'block'; clearTimeout(toastTimer); toastTimer = window.setTimeout( () => { toast.style.display = 'none'; }, 1800 ); } /** * 复制文字到剪贴板。 */ async function copyText(text) { try { if ( typeof GM_setClipboard === 'function' ) { GM_setClipboard( text, 'text' ); return true; } await navigator.clipboard.writeText(text); return true; } catch (error) { console.error( '复制失败:', error ); return false; } } /** * 执行处理。 */ async function executeProcessing(snapshot) { if (!snapshot || !snapshot.text) { showToast('请先选中文字'); return; } const processed = processSelectedText( snapshot.text ); resultText.value = processed.result; resultTitle.textContent = processed.decoded ? '已完成 Base64 解码,并删除汉字、标点和表情' : '未识别为 Base64,已删除汉字、标点和表情'; hideActionButton(); /* * 自动复制开启时: * 复制成功后直接关闭,不显示结果面板。 * 复制失败时显示结果面板,方便手动复制。 */ if (CONFIG.autoCopy) { const copied = await copyText( processed.result ); if (copied) { hideResultPanel(); showToast( processed.decoded ? 'Base64 解码结果已复制' : '清理结果已复制' ); return; } positionResultPanel(); showToast('自动复制失败,请手动复制'); return; } positionResultPanel(); } /** * 保存选区并显示悬浮按钮。 */ function captureSelection(x, y) { const snapshot = getSelectionSnapshot(); if ( !snapshot || !snapshot.text.trim() ) { selectionSnapshot = null; hideActionButton(); return; } selectionSnapshot = snapshot; positionActionButton( snapshot, Number.isFinite(x) ? x : lastMousePosition.x, Number.isFinite(y) ? y : lastMousePosition.y ); } /* * 记录鼠标位置。 */ document.addEventListener( 'mousemove', event => { lastMousePosition = { x: event.clientX, y: event.clientY }; }, true ); /* * 鼠标选中文字后显示按钮。 */ document.addEventListener( 'mouseup', event => { if ( actionButton.contains(event.target) || resultPanel.contains(event.target) ) { return; } lastMousePosition = { x: event.clientX, y: event.clientY }; window.setTimeout( () => { captureSelection( event.clientX, event.clientY ); }, 0 ); }, true ); /* * 支持键盘选中文字和快捷键处理。 */ document.addEventListener( 'keyup', event => { /* * 快捷键:Alt + Shift + B */ if ( event.altKey && event.shiftKey && event.key.toLowerCase() === CONFIG.shortcutKey ) { const snapshot = getSelectionSnapshot(); if (snapshot) { selectionSnapshot = snapshot; } executeProcessing( selectionSnapshot ); return; } /* * 支持 Shift + 方向键选择文字。 */ window.setTimeout( () => { captureSelection(); }, 0 ); }, true ); /* * 点击悬浮按钮时保留网页选区。 */ actionButton.addEventListener( 'mousedown', event => { event.preventDefault(); event.stopPropagation(); } ); /* * 点击“解码B64”按钮开始处理。 */ actionButton.addEventListener( 'click', event => { event.preventDefault(); event.stopPropagation(); executeProcessing( selectionSnapshot ); } ); /* * 手动复制结果。 * 复制成功后关闭结果面板。 */ copyButton.addEventListener( 'click', async () => { const copied = await copyText( resultText.value ); if (copied) { hideResultPanel(); hideActionButton(); showToast('结果已复制'); } else { showToast('复制失败'); } } ); /* * 关闭结果面板。 */ closeButton.addEventListener( 'click', () => { hideResultPanel(); hideActionButton(); } ); /* * 点击结果面板之外的位置时关闭。 */ document.addEventListener( 'mousedown', event => { if ( resultPanel.style.display === 'block' && !resultPanel.contains(event.target) && !actionButton.contains(event.target) ) { hideResultPanel(); } }, true ); /* * 页面滚动后隐藏悬浮按钮。 */ window.addEventListener( 'scroll', () => { hideActionButton(); }, true ); })();