|
// ==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/97adee1329d4de9f6ef21eeb500006dea9998508/V2EX%2520Base64%2520Enhance.js |
|
// @updateURL https://gist.github.com/zhuziyi1989/c728adf9c1d569e74362ca00818fe979/raw/97adee1329d4de9f6ef21eeb500006dea9998508/V2EX%2520Base64%2520Enhance.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); |
|
})(); |