// ==UserScript== // @name 智慧树复制粘贴解锁器 // @namespace https://github.com/zhihuishu-unlock // @version 1.3.0 // @description 解锁智慧树(zhihuishu.com)平台的右键菜单、文本选择、复制、剪切、粘贴功能;附带页面源码保存、全文复制浮动工具栏;支持 qiankun 微前端和 Shadow DOM // @author Doubao // @match *://*.zhihuishu.com/* // @match *://zhihuishu.com/* // @run-at document-start // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 配置区 // ============================================================ const CONFIG = { // 需要解锁的事件类型 blockedEvents: [ 'copy', 'cut', 'paste', 'contextmenu', 'selectstart', 'selectionchange', 'dragstart', 'drag', 'dragend', 'mousedown', 'mouseup', 'click', 'keydown', 'keyup', 'keypress' ], // 需要移除的内联事件属性 inlineHandlers: [ 'oncopy', 'oncut', 'onpaste', 'oncontextmenu', 'onselectstart', 'onselectionchange', 'ondragstart', 'ondrag', 'ondragend', 'onmousedown', 'onmouseup' ], // 轮询清理间隔(毫秒),应对微前端动态渲染 cleanupInterval: 1500, // 是否在控制台输出日志 debug: false }; // ============================================================ // 工具函数 // ============================================================ function log(...args) { if (CONFIG.debug) { console.log('%c[智慧树解锁]', 'color:#2C59F3;font-weight:bold;', ...args); } } // 判断是否为"阻断型"事件处理器(返回 false 或调用 preventDefault) // 我们在捕获阶段拦截,让事件正常冒泡到浏览器默认行为 function isBlockingEvent(event) { // 只拦截与复制/选择/右键相关的快捷键和操作 const type = event.type; // 右键菜单 if (type === 'contextmenu') return true; // 复制/剪切/粘贴 if (['copy', 'cut', 'paste'].includes(type)) return true; // 文本选择开始 if (type === 'selectstart') return true; // 拖拽(可能被用于阻止选择) if (type === 'dragstart') return true; // 键盘事件:只拦截复制粘贴相关快捷键 if (type === 'keydown' || type === 'keyup' || type === 'keypress') { const key = event.key?.toLowerCase(); const ctrl = event.ctrlKey || event.metaKey; if (ctrl && ['c', 'x', 'v', 'a', 's', 'p', 'u', 'f', 'g', 'h', 'l', 'r', 't', 'w', 'n'].includes(key)) { return true; } // F12 开发者工具 if (key === 'f12') return true; // Ctrl+Shift+I / J / C if (ctrl && event.shiftKey && ['i', 'j', 'c'].includes(key)) return true; } // 鼠标事件:只在非输入区域拦截 mousedown(防止禁止选择) if (type === 'mousedown') { const target = event.target; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { return false; // 输入框不拦截 } // 只拦截中键和右键相关的 mousedown if (event.button === 1 || event.button === 2) return true; } return false; } // ============================================================ // 核心:捕获阶段事件拦截 // 在页面脚本之前拦截阻断型事件,阻止其调用 preventDefault // ============================================================ function installCaptureInterceptors(target) { if (!target || target.__unlockInstalled) return; target.__unlockInstalled = true; CONFIG.blockedEvents.forEach(eventType => { target.addEventListener(eventType, function (event) { if (isBlockingEvent(event)) { // 停止事件继续传播到页面的阻断处理器 // 但不阻止浏览器默认行为(这样复制/右键菜单仍然有效) event.stopImmediatePropagation(); log('拦截阻断事件:', eventType, event.target?.tagName); } }, true); // capture=true: 在捕获阶段拦截,先于页面处理器执行 }); log('已安装事件拦截器到:', target === window ? 'window' : target === document ? 'document' : target.tagName); } // ============================================================ // CSS 注入:强制允许文本选择 // ============================================================ function injectAllowSelectCSS(targetDoc) { if (!targetDoc) targetDoc = document; if (targetDoc.getElementById('zhihuishu-unlock-css')) return; const css = ` /* 强制允许所有元素的文本选择 */ *, *::before, *::after { -webkit-user-select: text !important; -moz-user-select: text !important; -ms-user-select: text !important; user-select: text !important; -webkit-touch-callout: default !important; } /* 允许输入框和文本域的正常行为 */ input, textarea { -webkit-user-select: text !important; user-select: text !important; } /* 恢复右键菜单 */ body { -webkit-touch-callout: default !important; } /* 允许拖拽文本 */ [draggable="false"] { draggable: auto; } `; const style = targetDoc.createElement('style'); style.id = 'zhihuishu-unlock-css'; style.textContent = css; (targetDoc.head || targetDoc.documentElement).appendChild(style); log('CSS 已注入'); } // ============================================================ // 移除内联事件处理器 // ============================================================ function removeInlineHandlers(root) { if (!root) root = document; CONFIG.inlineHandlers.forEach(handler => { // 移除根元素自身的 if (root.hasAttribute && root.hasAttribute(handler)) { root.removeAttribute(handler); log('移除内联属性:', handler, 'from', root.tagName); } // 移除所有子元素的 const elements = root.querySelectorAll(`[${handler}]`); elements.forEach(el => { el.removeAttribute(handler); log('移除内联属性:', handler, 'from', el.tagName + '.' + (el.className || '')); }); }); // 同时清除 DOM 属性形式的处理器 if (root.querySelectorAll) { const all = root.querySelectorAll('*'); all.forEach(el => { CONFIG.inlineHandlers.forEach(handler => { if (el[handler]) { el[handler] = null; } }); }); } } // ============================================================ // 解除元素的 draggable=false // ============================================================ function unlockDraggable(root) { if (!root || !root.querySelectorAll) return; const elements = root.querySelectorAll('[draggable="false"]'); elements.forEach(el => { el.removeAttribute('draggable'); }); } // ============================================================ // 处理 iframe(智慧树微应用可能使用 iframe) // ============================================================ function unlockIframes() { const iframes = document.querySelectorAll('iframe'); iframes.forEach(iframe => { try { const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; if (iframeDoc) { installCaptureInterceptors(iframeDoc); installCaptureInterceptors(iframe.contentWindow); injectAllowSelectCSS(iframeDoc); removeInlineHandlers(iframeDoc); log('已解锁 iframe:', iframe.src?.substring(0, 80)); } } catch (e) { // 跨域 iframe 无法访问,跳过 log('跨域 iframe 跳过:', iframe.src?.substring(0, 80)); } }); } // ============================================================ // 全面清理函数(应对 qiankun 微前端动态渲染) // ============================================================ function fullCleanup() { removeInlineHandlers(document); unlockDraggable(document); unlockShadowDOM(document); unlockIframes(); } // ============================================================ // MutationObserver:监听 DOM 变化,自动解锁新添加的内容 // ============================================================ function installMutationObserver() { const observer = new MutationObserver((mutations) => { let needsCleanup = false; for (const mutation of mutations) { if (mutation.addedNodes && mutation.addedNodes.length > 0) { needsCleanup = true; break; } } if (needsCleanup) { // 延迟执行,避免频繁调用 clearTimeout(window.__unlockMutationTimer); window.__unlockMutationTimer = setTimeout(fullCleanup, 300); } }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: CONFIG.inlineHandlers.concat(['draggable', 'style']) }); log('MutationObserver 已启动'); } // ============================================================ // 增强:重写 Event.prototype.preventDefault // 对于复制/右键相关事件,阻止页面调用 preventDefault // ============================================================ function patchEventPreventDefault() { const originalPreventDefault = Event.prototype.preventDefault; Event.prototype.preventDefault = function () { if (isBlockingEvent(this)) { log('阻止页面调用 preventDefault:', this.type); return; // 不执行真正的 preventDefault } return originalPreventDefault.apply(this, arguments); }; log('Event.prototype.preventDefault 已补丁'); } // ============================================================ // 增强:重写 window.getSelection 相关限制 // 确保文本选择功能正常 // ============================================================ function ensureSelectionWorks() { // 某些页面会通过清除 selection 来阻止复制 // 我们监控 selectionchange,不做额外处理(事件拦截已足够) document.addEventListener('selectionchange', function (e) { // 不阻止,让选择正常工作 }, true); } // ============================================================ // Shadow DOM 支持:穿透 shadow root 解锁内部元素 // ============================================================ function unlockShadowDOM(root) { if (!root || !root.querySelectorAll) return; // 查找所有带 shadowRoot 的元素 const allElements = root.querySelectorAll('*'); allElements.forEach(el => { if (el.shadowRoot) { try { installCaptureInterceptors(el.shadowRoot); injectAllowSelectCSS(el.shadowRoot); removeInlineHandlers(el.shadowRoot); log('已解锁 Shadow DOM:', el.tagName); // 递归处理 shadow DOM 内部的嵌套 shadow DOM unlockShadowDOM(el.shadowRoot); } catch (e) { log('Shadow DOM 解锁失败:', e.message); } } }); } // ============================================================ // 浮动工具栏:保存页面源码 / 复制全文 / 复制选中文字 // ============================================================ function createFloatingToolbar() { if (document.getElementById('zhihuishu-unlock-toolbar')) return; const toolbar = document.createElement('div'); toolbar.id = 'zhihuishu-unlock-toolbar'; toolbar.style.cssText = ` position: fixed; top: 80px; right: 12px; z-index: 2147483647; display: flex; flex-direction: column; gap: 6px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; opacity: 0.85; transition: opacity 0.2s; `; toolbar.onmouseenter = () => toolbar.style.opacity = '1'; toolbar.onmouseleave = () => toolbar.style.opacity = '0.85'; const buttonStyle = ` padding: 8px 12px; background: #2C59F3; color: white; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transition: background 0.2s; `; // 按钮1:保存页面源码 const btnSave = document.createElement('button'); btnSave.textContent = '💾 保存页面源码'; btnSave.style.cssText = buttonStyle; btnSave.onmouseenter = () => btnSave.style.background = '#1a45d9'; btnSave.onmouseleave = () => btnSave.style.background = '#2C59F3'; btnSave.onclick = () => { const html = '\n' + document.documentElement.outerHTML; const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'zhihuishu_page_' + Date.now() + '.html'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('页面源码已开始下载'); }; // 按钮2:复制全文文本 const btnCopyAll = document.createElement('button'); btnCopyAll.textContent = '📋 复制全文文本'; btnCopyAll.style.cssText = buttonStyle; btnCopyAll.onmouseenter = () => btnCopyAll.style.background = '#1a45d9'; btnCopyAll.onmouseleave = () => btnCopyAll.style.background = '#2C59F3'; btnCopyAll.onclick = () => { const text = document.body.innerText || document.body.textContent; copyToClipboard(text); showToast('全文已复制到剪贴板(' + text.length + ' 字)'); }; // 按钮3:复制选中文字 const btnCopySel = document.createElement('button'); btnCopySel.textContent = '✂️ 复制选中文字'; btnCopySel.style.cssText = buttonStyle; btnCopySel.onmouseenter = () => btnCopySel.style.background = '#1a45d9'; btnCopySel.onmouseleave = () => btnCopySel.style.background = '#2C59F3'; btnCopySel.onclick = () => { const sel = window.getSelection(); const text = sel ? sel.toString() : ''; if (text) { copyToClipboard(text); showToast('选中文字已复制(' + text.length + ' 字)'); } else { showToast('请先选中要复制的文字'); } }; // 按钮4:显示/隐藏工具栏(折叠) const btnToggle = document.createElement('button'); btnToggle.textContent = '🔓'; btnToggle.style.cssText = ` padding: 6px 10px; background: rgba(44,89,243,0.15); color: #2C59F3; border: 1px solid #2C59F3; border-radius: 6px; font-size: 14px; cursor: pointer; align-self: flex-end; `; let expanded = true; const actionButtons = [btnSave, btnCopyAll, btnCopySel]; btnToggle.onclick = () => { expanded = !expanded; actionButtons.forEach(b => b.style.display = expanded ? 'block' : 'none'); btnToggle.textContent = expanded ? '🔓' : '🔒'; }; toolbar.appendChild(btnToggle); toolbar.appendChild(btnSave); toolbar.appendChild(btnCopyAll); toolbar.appendChild(btnCopySel); document.body.appendChild(toolbar); log('浮动工具栏已创建'); } // 复制到剪贴板(兼容多种方式) function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); } else { fallbackCopy(text); } } function fallbackCopy(text) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) { log('复制失败:', e); } document.body.removeChild(ta); } // Toast 提示 function showToast(message) { const existing = document.getElementById('zhihuishu-unlock-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.id = 'zhihuishu-unlock-toast'; toast.textContent = message; toast.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 12px 24px; border-radius: 8px; font-size: 14px; z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; animation: zhihuishu-toast-fade 0.3s ease; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, 2000); } // ============================================================ // 初始化 // ============================================================ function init() { log('=== 智慧树复制粘贴解锁器启动 ==='); log('当前 URL:', location.href); // 1. 安装事件拦截器(最核心) installCaptureInterceptors(window); installCaptureInterceptors(document); // 2. 补丁 Event.preventDefault patchEventPreventDefault(); // 3. 注入 CSS injectAllowSelectCSS(document); // 4. 清理已有内联处理器 removeInlineHandlers(document); unlockDraggable(document); unlockShadowDOM(document); // 5. 确保选择功能 ensureSelectionWorks(); // 6. 创建浮动工具栏 if (document.body) { createFloatingToolbar(); } else { document.addEventListener('DOMContentLoaded', createFloatingToolbar, { once: true }); } // 6. 启动 MutationObserver(应对动态内容) if (document.documentElement) { installMutationObserver(); } else { document.addEventListener('DOMContentLoaded', installMutationObserver, { once: true }); } // 7. 定时全面清理(qiankun 微应用可能延迟加载) setInterval(fullCleanup, CONFIG.cleanupInterval); // 8. 页面加载完成后再做一次全面清理 if (document.readyState === 'complete') { setTimeout(fullCleanup, 1000); } else { window.addEventListener('load', () => setTimeout(fullCleanup, 1000), { once: true }); } // 9. 处理 hash 变化(SPA 路由) window.addEventListener('hashchange', () => { log('检测到 hash 变化,重新清理'); setTimeout(fullCleanup, 500); }); log('=== 初始化完成 ==='); } // 立即执行(@run-at document-start 保证最早执行) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); // 同时在 document-start 阶段先安装 window 级拦截器 installCaptureInterceptors(window); patchEventPreventDefault(); } else { init(); } })();