// ==UserScript== // @name V2EX Base64 Enhance 自动解析增强版 // @namespace http://tampermonkey.net/ // @version 1.3 // @description 在 V2EX 帖子详情页自动发现 Base64 字符串并在其后追加解码结果;若解码为 URL 则渲染为可点击链接(在新标签打开)。仅处理帖子正文与回复内容,避免页面卡死。 // @author zhuziyi // @match https://v2ex.com/t/* // @match https://*.v2ex.com/t/* // @grant none // @run-at document-idle // @supportURL https://gist.github.com/zhuziyi1989/c728adf9c1d569e74362ca00818fe979 // @homepageURL https://gist.github.com/zhuziyi1989/c728adf9c1d569e74362ca00818fe979 // @downloadURL https://gist.github.com/zhuziyi1989/c728adf9c1d569e74362ca00818fe979/raw/v2ex-base64-enhance.js // @updateURL https://gist.github.com/zhuziyi1989/c728adf9c1d569e74362ca00818fe979/raw/v2ex-base64-enhance.js // ==/UserScript== (function () { "use strict"; const MIN_B64_LEN = 16; // 最小 Base64 长度阈值,按需调整 const SKIP_TAGS = new Set([ "SCRIPT", "STYLE", "CODE", "PRE", "TEXTAREA", "INPUT", "A", ]); const B64_RE = new RegExp( `([A-Za-z0-9_\\-+\\/]{${MIN_B64_LEN},}={0,2})`, "g" ); const APP_MARK = "data-b64-script"; // 我们插入节点时会打上这个标记 // 记录已处理过的文本节点,避免重复处理 const processedTextNodes = new WeakSet(); function normalizeBase64(b64) { let s = b64.replace(/\s+/g, ""); s = s.replace(/-/g, "+").replace(/_/g, "/"); const pad = s.length % 4; if (pad === 1) return null; if (pad !== 0) s += "=".repeat(4 - pad); return s; } function decodeBase64ToString(b64) { try { const norm = normalizeBase64(b64); if (!norm) return null; const binary = atob(norm); // binary 是 Latin1,需要转为 UTF-8 let percentEncoded = ""; for (let i = 0; i < binary.length; i++) { const code = binary.charCodeAt(i); percentEncoded += "%" + ("00" + code.toString(16)).slice(-2); } try { return decodeURIComponent(percentEncoded); } catch (e) { // 退回为原 binary(可能是 Latin1 文本) return binary; } } catch (e) { return null; } } function looksReadable(s) { if (!s || s.length < 3) return false; if (/[\u4e00-\u9fff]/.test(s)) return true; // 含中文 if (/(https?:\/\/|www\.)/i.test(s)) return true; // 至少有 60% 的可打印 ASCII 字符 const printableCount = (s.match(/[\x20-\x7E]/g) || []).length; if (printableCount / s.length >= 0.6) return true; return false; } function createAppendNode(decodedText) { const wrapper = document.createElement("span"); wrapper.setAttribute(APP_MARK, "1"); wrapper.style.marginLeft = "6px"; wrapper.style.fontSize = "0.95em"; wrapper.style.verticalAlign = "baseline"; const trimmed = decodedText.trim(); const isURL = /^https?:\/\//i.test(trimmed); if (isURL) { // 全角括号包裹的可点链接 const left = document.createTextNode("("); const right = document.createTextNode(")"); const a = document.createElement("a"); a.href = trimmed; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = trimmed; // 不强制设置颜色,保持与站点主题兼容;若需自定义可修改以下两行 a.style.textDecoration = "underline"; a.style.cursor = "pointer"; wrapper.appendChild(left); wrapper.appendChild(a); wrapper.appendChild(right); } else { wrapper.style.color = "#888"; wrapper.textContent = `(${trimmed})`; } return wrapper; } function processTextNode(textNode) { if (!textNode || !textNode.nodeValue) return; if (processedTextNodes.has(textNode)) return; const parent = textNode.parentNode; if (!parent || SKIP_TAGS.has(parent.tagName)) return; // 如果父元素或祖先已是我们插入内容的标记,则跳过(避免在我们的 span 内再次处理) if (parent.closest && parent.closest("[" + APP_MARK + "]")) return; const text = textNode.nodeValue; B64_RE.lastIndex = 0; let match; let lastIndex = 0; const frag = document.createDocumentFragment(); let anyMatch = false; while ((match = B64_RE.exec(text)) !== null) { const b64 = match[1]; // 把前面的纯文本加入 const before = text.slice(lastIndex, match.index); if (before) frag.appendChild(document.createTextNode(before)); // 原 Base64 文本(保持原样) const origNode = document.createTextNode(b64); frag.appendChild(origNode); // 尝试解码并判断是否可读 const decoded = decodeBase64ToString(b64); if (decoded && looksReadable(decoded)) { const appendNode = createAppendNode(decoded); frag.appendChild(appendNode); } lastIndex = match.index + b64.length; anyMatch = true; } if (!anyMatch) { // 标记为已检查过(避免下次又检查同一节点) processedTextNodes.add(textNode); return; } // 尾部文本 const tail = text.slice(lastIndex); if (tail) frag.appendChild(document.createTextNode(tail)); // 替换原 text node(注意:替换后原节点不再存在) try { parent.replaceChild(frag, textNode); } catch (e) { // 如果替换失败(极少见),仍将该节点标记以避免重复 processedTextNodes.add(textNode); return; } // 无需标记新创建的文本节点;但为了避免再次检查同一位置,我们可以标记父元素(轻量) // 在父元素上打一个短期属性,表明它已经被扫描(不会影响页面) try { parent.setAttribute("data-b64-scanned", Date.now().toString()); } catch (e) { // 忽略不可设置属性的情况(例如某些 SVG 节点) } } function walkAndProcess(root) { if (!root) return; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; const p = node.parentNode; if (!p) return NodeFilter.FILTER_REJECT; if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT; if (p.closest && p.closest("[" + APP_MARK + "]")) return NodeFilter.FILTER_REJECT; if (processedTextNodes.has(node)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }, false ); const nodes = []; let n; while ((n = walker.nextNode())) nodes.push(n); for (const tn of nodes) processTextNode(tn); } // 找到需要处理的区域(帖子正文与回复) function findTargets() { const nodes = []; const main = document.querySelector(".topic_content"); if (main) nodes.push(main); // 回复区常见类名 document .querySelectorAll(".reply_content, .message, .topic .cell .reply_content") .forEach((n) => { if (n) nodes.push(n); }); return nodes; } function initScan() { const targets = findTargets(); if (targets.length === 0) return; for (const t of targets) walkAndProcess(t); } // 监听帖子区域的变化(只对目标区域设置 observer) const observers = []; function initObservers() { const targets = findTargets(); if (targets.length === 0) return; for (const t of targets) { const mo = new MutationObserver((mutations) => { for (const m of mutations) { // 跳过我们自己插入的节点(根节点带标记) if (m.addedNodes && m.addedNodes.length) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // 如果新增节点是我们自己创建的标记子树,跳过 if (node.hasAttribute && node.hasAttribute(APP_MARK)) continue; // 如果新增元素内部含有我们的标记,也跳过 if ( node.querySelector && node.querySelector("[" + APP_MARK + "]") ) continue; // 对新增元素内部做扫描 walkAndProcess(node); } else if (node.nodeType === Node.TEXT_NODE) { // 直接处理文本节点 processTextNode(node); } } } if (m.type === "characterData" && m.target) { processTextNode(m.target); } } }); mo.observe(t, { childList: true, subtree: true, characterData: true }); observers.push(mo); } } // 初次运行 initScan(); initObservers(); // 暴露一个手动触发的函数(控制台可用),方便调试/手动重新扫描 window.__v2ex_b64_rescan = function () { initScan(); console.log("v2ex-base64: manual rescan done"); }; console.log("v2ex-base64 (post-only) loaded"); // ============ 新增:Base64 加密与解密 选中文本 ============ // Base64 编码 function encodeBase64(str) { try { return btoa(unescape(encodeURIComponent(str))); } catch (e) { return str; } } // Base64 解码 function decodeBase64(str) { try { return decodeURIComponent(escape(atob(str))); } catch (e) { return str; } } // ============ 新:自动隐藏按钮 ============ function updateButtonVisibility() { const textarea = document.querySelector("#reply_content"); const encodeBtn = document.getElementById("base64_encode_btn"); const decodeBtn = document.getElementById("base64_decode_btn"); if (!textarea || !encodeBtn || !decodeBtn) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; // 无选区 → 按钮隐藏,但保留占位 if (start === end) { encodeBtn.style.visibility = "hidden"; decodeBtn.style.visibility = "hidden"; } else { encodeBtn.style.visibility = "visible"; decodeBtn.style.visibility = "visible"; } } function addBase64Buttons() { const replyBtn = document.querySelector( 'input[type="submit"][value="回复"]' ); replyBtn.parentElement.style.justifyContent = "flex-start"; replyBtn.parentElement.style.flexDirection = "row-reverse"; const textarea = document.querySelector("#reply_content"); if (!replyBtn || !textarea) return; if (document.getElementById("base64_encode_btn")) return; // 按钮创建器 function createBtn(text, id) { const btn = document.createElement("button"); btn.id = id; btn.textContent = text; btn.style.marginRight = "8px"; btn.style.padding = "5px 8px"; btn.style.border = "1px solid #f5f5f5"; btn.style.background = "#f5f5f5"; btn.style.borderRadius = "5px"; btn.style.cursor = "pointer"; btn.style.fontSize = "12px"; btn.style.lineHeight = "1.4"; btn.style.color = "#9349ff"; btn.style.visibility = "hidden"; // 初始隐藏 return btn; } const encodeBtn = createBtn("🔐 Base64编码", "base64_encode_btn"); encodeBtn.addEventListener("click", (e) => { e.preventDefault(); const start = textarea.selectionStart; const end = textarea.selectionEnd; const txt = textarea.value.substring(start, end); textarea.value = textarea.value.substring(0, start) + encodeBase64(txt) + textarea.value.substring(end); textarea.selectionStart = textarea.selectionEnd = start + encodeBase64(txt).length; textarea.focus(); updateButtonVisibility(); }); const decodeBtn = createBtn("🔓 Base64解码", "base64_decode_btn"); decodeBtn.addEventListener("click", (e) => { e.preventDefault(); const start = textarea.selectionStart; const end = textarea.selectionEnd; const txt = textarea.value.substring(start, end); textarea.value = textarea.value.substring(0, start) + decodeBase64(txt) + textarea.value.substring(end); textarea.selectionStart = textarea.selectionEnd = start + decodeBase64(txt).length; textarea.focus(); updateButtonVisibility(); }); replyBtn.parentNode.insertBefore(encodeBtn, replyBtn.nextSibling); replyBtn.parentNode.insertBefore(decodeBtn, encodeBtn.nextSibling); // 监听 selection 改变 → 控制可见性 ["select", "keyup", "mouseup"].forEach((evt) => { textarea.addEventListener(evt, updateButtonVisibility); }); } setTimeout(addBase64Buttons, 1500); })();