Enable Select & Copy — WL/BL + Mode Switch + Hotkey

解除网页复制/选中/右键限制;支持白/黑名单模式及切换、站点级覆盖、菜单管理清单、快捷键总开关与 Alt+Shift+C 切换

Versão de: 06/09/2025. Veja: a última versão.

// ==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);
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。