// ==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
);
})();