// ==UserScript==
// @name Enable Select & Copy — WL/BL + Mode Switch + Hotkey
// @namespace tm-copy-unlock
// @version 3.0
// @description 解除网页复制/选中/右键限制;支持白/黑名单模式及切换、站点级覆盖、菜单管理清单、快捷键总开关与 Alt+Shift+C 切换
// @match *://*/*
// @run-at document-start
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ========== 0) 默认配置(可按需改) ==========
// 默认白名单(仅在这些域名上启用;支持 *.domain.com)
const DEFAULT_WHITELIST = [
'*.zhihu.com',
'*.jianshu.com',
'*.csdn.net',
// 'example.com',
];
// 默认黑名单(黑名单模式下,这些域名禁用)
const DEFAULT_BLACKLIST = [
// '*.some-interactive-site.com',
];
// ========== 1) 常量 & 存储 Key ==========
const STORAGE = {
MODE: 'copyUnlock.mode', // 'whitelist' | 'blacklist'
WL: 'copyUnlock.whitelist',
BL: 'copyUnlock.blacklist',
SITE_OVERRIDE_PREFIX: 'copyUnlock.site.', // + hostname => true/false/null
HOTKEY_ENABLED: 'copyUnlock.hotkeyEnabled',
};
// ========== 2) 工具函数 ==========
const host = location.hostname;
function arr(val, fallback = []) {
return Array.isArray(val) ? val : fallback;
}
function unique(a) {
return Array.from(new Set(a));
}
function save(key, value) {
GM_setValue(key, value);
}
function read(key, fallback) {
const v = GM_getValue(key);
return v === undefined ? fallback : v;
}
function escRegex(s) {
return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
function globToReg(glob) {
return new RegExp('^' + glob.split('*').map(escRegex).join('.*') + '$', 'i');
}
function matchAny(hostname, globs) {
const regs = globs.map(globToReg);
return regs.some(r => r.test(hostname));
}
// 将当前站点标准化为通配:三级及以上 => *.domain.tld;否则原样
function currentHostGlob() {
const parts = host.split('.');
if (parts.length >= 3) return '*.' + parts.slice(-2).join('.');
return host;
}
function toast(msg) {
// 轻提示:避免页面没权限时 alert 阻塞
try {
console.log('[CopyUnlock]', msg);
alert(msg);
} catch {
/* eslint-disable no-console */
console.log('[CopyUnlock]', msg);
}
}
// ========== 3) 读取 / 合并清单 & 模式 ==========
const persistedWL = arr(read(STORAGE.WL, []));
const persistedBL = arr(read(STORAGE.BL, []));
const WL = unique([...(DEFAULT_WHITELIST || []), ...persistedWL]);
const BL = unique([...(DEFAULT_BLACKLIST || []), ...persistedBL]);
let mode = read(STORAGE.MODE, 'whitelist'); // 'whitelist' | 'blacklist' (默认白名单)
if (mode !== 'whitelist' && mode !== 'blacklist') {
mode = 'whitelist';
save(STORAGE.MODE, mode);
}
// 站点覆盖:true=强制开, false=强制关, null/undefined=按模式
const SITE_KEY = STORAGE.SITE_OVERRIDE_PREFIX + host;
let siteOverride = read(SITE_KEY, null);
// 快捷键总开关
let hotkeyEnabled = read(STORAGE.HOTKEY_ENABLED, true);
// 根据模式决定是否启用
const inWL = matchAny(host, WL);
const inBL = matchAny(host, BL);
function calcEffectiveOn() {
if (siteOverride === true) return true;
if (siteOverride === false) return false;
if (mode === 'whitelist') return inWL;
// 黑名单模式:只要不在黑名单,就启用
return !inBL;
}
let effectiveOn = calcEffectiveOn();
// ========== 4) 菜单:模式切换、站点覆盖、清单管理、快捷键总开关 ==========
function toggleMode() {
mode = (mode === 'whitelist') ? 'blacklist' : 'whitelist';
save(STORAGE.MODE, mode);
effectiveOn = calcEffectiveOn();
toast(`已切换为【${mode === 'whitelist' ? '白名单' : '黑名单'}】模式。\n当前站点(${host})状态:${effectiveOn ? '启用' : '禁用'}`);
}
function setSiteOverride(val /* true/false/null */) {
siteOverride = (val === null) ? null : !!val;
if (val === null) {
// 清除覆盖
save(SITE_KEY, null);
effectiveOn = calcEffectiveOn();
toast(`已清除当前站点覆盖设定(${host})。\n按模式:${mode === 'whitelist' ? '白名单' : '黑名单'} → ${effectiveOn ? '启用' : '禁用'}`);
} else {
save(SITE_KEY, siteOverride);
effectiveOn = calcEffectiveOn();
toast(`已${siteOverride ? '开启' : '关闭'} 当前站点解锁复制(${host})。`);
}
}
function addToList(key, listArr, glob) {
if (!glob) glob = currentHostGlob();
listArr.push(glob);
save(key, unique(listArr));
toast(`已加入:${glob}\n列表:${key}`);
}
function removeFromList(key, listArr, glob) {
if (!glob) glob = currentHostGlob();
const idx = listArr.indexOf(glob);
if (idx >= 0) {
listArr.splice(idx, 1);
save(key, unique(listArr));
toast(`已移除:${glob}\n列表:${key}`);
} else {
toast(`未找到:${glob}\n列表:${key}`);
}
}
GM_registerMenuCommand(
`当前模式:${mode === 'whitelist' ? '白名单' : '黑名单'}(点击切换)`,
toggleMode,
{ autoClose: true }
);
GM_registerMenuCommand(
`${effectiveOn ? '关闭' : '开启'} 当前站点解锁复制`,
() => setSiteOverride(!effectiveOn),
{ autoClose: true }
);
GM_registerMenuCommand(
'清除当前站点覆盖(恢复按模式判断)',
() => setSiteOverride(null),
{ autoClose: true }
);
GM_registerMenuCommand(
'将当前站点加入白名单',
() => addToList(STORAGE.WL, persistedWL, currentHostGlob()),
{ autoClose: true }
);
GM_registerMenuCommand(
'将当前站点移出白名单',
() => removeFromList(STORAGE.WL, persistedWL, currentHostGlob()),
{ autoClose: true }
);
GM_registerMenuCommand(
'将当前站点加入黑名单',
() => addToList(STORAGE.BL, persistedBL, currentHostGlob()),
{ autoClose: true }
);
GM_registerMenuCommand(
'将当前站点移出黑名单',
() => removeFromList(STORAGE.BL, persistedBL, currentHostGlob()),
{ autoClose: true }
);
GM_registerMenuCommand(
'查看白/黑名单',
() => {
const curGlob = currentHostGlob();
toast(
`当前站点通配:${curGlob}\n\n白名单:\n${JSON.stringify(unique(WL), null, 2)}\n\n黑名单:\n${JSON.stringify(unique(BL), null, 2)}`
);
},
{ autoClose: true }
);
GM_registerMenuCommand(
`${hotkeyEnabled ? '关闭' : '开启'} 快捷键 (Alt+Shift+C)`,
() => {
hotkeyEnabled = !hotkeyEnabled;
save(STORAGE.HOTKEY_ENABLED, hotkeyEnabled);
toast(`快捷键已${hotkeyEnabled ? '开启' : '关闭'}。`);
},
{ autoClose: true }
);
// 快捷键:Alt+Shift+C → 切换当前站点覆盖(开/关)
window.addEventListener(
'keydown',
e => {
if (!hotkeyEnabled) return;
if (e.altKey && e.shiftKey && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
setSiteOverride(!effectiveOn);
}
},
true
);
// 如果当前不启用,直接退出(保留菜单)
if (!effectiveOn) return;
// ========== 5) 解锁复制逻辑 ==========
const BLOCKED = new Set(['copy', 'cut', 'contextmenu', 'selectstart', 'dragstart', 'beforecopy', 'keydown']);
// 允许选中/右键 等
const css = `
html, body, * {
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
user-select: text !important;
-webkit-touch-callout: default !important;
pointer-events: auto !important;
}
*[unselectable="on"] {
-webkit-user-select: text !important;
user-select: text !important;
}
`;
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
} else {
const st = document.createElement('style');
st.textContent = css;
document.documentElement.appendChild(st);
}
// 捕获阶段拦截站点绑定的“禁复制”监听(不破坏浏览器默认复制)
const stopper = e => {
if (e.type === 'keydown') {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const meta = isMac ? e.metaKey : e.ctrlKey;
if (meta && (e.key === 'c' || e.key === 'C')) return; // 放行 Ctrl/⌘+C
}
// 不 preventDefault,只阻止站点自己的监听链
e.stopImmediatePropagation();
};
for (const t of BLOCKED) {
window.addEventListener(t, stopper, true);
document.addEventListener(t, stopper, true);
}
// 清理常见 inline 阻断 + 强制可选
const inlineAttrs = ['oncopy', 'oncut', 'oncontextmenu', 'onselectstart', 'ondragstart', 'onbeforecopy', 'onkeydown'];
function cleanNode(el) {
if (!el || el.nodeType !== 1) return;
for (const a of inlineAttrs) {
if (a in el) {
try { el[a] = null; } catch {}
}
if (el.hasAttribute && el.hasAttribute(a)) {
el.removeAttribute(a);
}
}
if (el.getAttribute && el.getAttribute('unselectable') === 'on') {
el.removeAttribute('unselectable');
}
if (el.style) {
try {
el.style.setProperty('user-select', 'text', 'important');
el.style.setProperty('-webkit-user-select', 'text', 'important');
} catch {}
}
}
function scanTree(root) {
cleanNode(root);
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT);
let n;
while ((n = it.nextNode())) cleanNode(n);
}
const mo = new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'attributes') cleanNode(m.target);
else if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => { if (n.nodeType === 1) scanTree(n); });
}
}
});
function start() {
scanTree(document.documentElement);
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
// Shadow DOM 支持:覆盖 attachShadow,在 shadow root 内注入相同逻辑
const _attach = Element.prototype.attachShadow;
if (_attach) {
Element.prototype.attachShadow = function (init) {
const root = _attach.call(this, init);
try {
const st = document.createElement('style');
st.textContent = css;
root.appendChild(st);
for (const t of BLOCKED) root.addEventListener(t, stopper, true);
const mo2 = new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'attributes') cleanNode(m.target);
else if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(n => { if (n.nodeType === 1) scanTree(n); });
}
}
});
mo2.observe(root, { subtree: true, childList: true, attributes: true });
} catch {}
return root;
};
}
// 兜底:周期性把 html/body 拉回可选,防止站点反复写死
setInterval(() => {
[document.documentElement, document.body].forEach(n => n && cleanNode(n));
}, 1500);
})();