// ==UserScript== // @name 智慧树复制粘贴解锁器 // @namespace https://github.com/zhihuishu-unlock // @version 1.4.0 // @description 解锁智慧树(zhihuishu.com)平台的右键菜单、文本选择、复制、剪切、粘贴功能;附带页面源码保存、复制最新AI消息、读取剪切板并发送浮动工具栏;支持 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); } } }); } // ============================================================ // 获取最新 AI(bot)消息文本 // 智慧树案例研讨页面:.question-discussion__message--bot // ============================================================ function getLatestBotMessage() { // 选择器列表,按优先级尝试(兼容页面改版) const selectors = [ '.question-discussion__message--bot .question-discussion__message-content', '.message--bot .message-content', '[class*="message--bot"] [class*="message-content"]', '[class*="bot"] [class*="message-content"]', '.chat-message.bot .chat-content' ]; for (const selector of selectors) { const elements = document.querySelectorAll(selector); if (elements.length > 0) { // 取最后一个(最新的)bot 消息 const latest = elements[elements.length - 1]; // 用 innerText 获取带换行的渲染文本,比 textContent 更准确 let text = latest.innerText || latest.textContent || ''; // 清理多余空行 text = text.replace(/\n{3,}/g, '\n\n').trim(); if (text) { return { text, source: selector, count: elements.length }; } } } return { text: '', source: null, count: 0 }; } // ============================================================ // 浮动工具栏:保存页面源码 / 复制最新AI消息 / 复制选中文字 // ============================================================ 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:复制最新AI消息 const btnCopyBot = document.createElement('button'); btnCopyBot.textContent = '🤖 复制最新AI消息'; btnCopyBot.style.cssText = buttonStyle; btnCopyBot.onmouseenter = () => btnCopyBot.style.background = '#1a45d9'; btnCopyBot.onmouseleave = () => btnCopyBot.style.background = '#2C59F3'; btnCopyBot.onclick = () => { const result = getLatestBotMessage(); if (result.text) { copyToClipboard(result.text); showToast('最新AI消息已复制(' + result.text.length + ' 字)'); } else { showToast('未找到AI消息,请确认页面已加载对话'); } }; // 按钮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 btnPasteSend = document.createElement('button'); btnPasteSend.textContent = '📤 读取剪切板并发送'; btnPasteSend.style.cssText = buttonStyle + 'background: #00B42A;'; btnPasteSend.onmouseenter = () => btnPasteSend.style.background = '#009a23'; btnPasteSend.onmouseleave = () => btnPasteSend.style.background = '#00B42A'; btnPasteSend.onclick = async () => { try { // 1. 读取剪切板 let clipboardText = ''; if (navigator.clipboard && navigator.clipboard.readText) { clipboardText = await navigator.clipboard.readText(); } if (!clipboardText) { // 降级方案:用临时 textarea + paste 事件 const ta = document.createElement('textarea'); ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.focus(); document.execCommand('paste'); clipboardText = ta.value; document.body.removeChild(ta); } if (!clipboardText || !clipboardText.trim()) { showToast('剪切板为空,请先复制内容'); return; } // 2. 找到输入框并填入 const inputSelectors = [ 'textarea.message-textarea', '.question-discussion textarea', '.chat-input-container textarea', 'textarea[placeholder*="输入"]', 'textarea[placeholder*="分析"]', 'textarea' ]; let textarea = null; for (const sel of inputSelectors) { textarea = document.querySelector(sel); if (textarea) break; } if (!textarea) { showToast('未找到输入框'); return; } // 3. 用原生 setter 设值(兼容 Vue/React 受控组件) const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' ).set; nativeInputValueSetter.call(textarea, clipboardText); // 4. 触发 input 事件,让框架检测到变化 textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('change', { bubbles: true })); // 5. 找到发送按钮并点击 const sendSelectors = [ '.send-button:not([disabled])', 'button.send-button', '.chat-input-container button[type="submit"]', 'button[title="发送消息"]', 'button[aria-label*="发送"]' ]; let sendBtn = null; for (const sel of sendSelectors) { sendBtn = document.querySelector(sel); if (sendBtn && !sendBtn.disabled) break; if (sendBtn) break; // 即使 disabled 也先拿到,后面再尝试启用 } if (sendBtn) { // 如果按钮仍 disabled,尝试移除 disabled 属性 if (sendBtn.disabled) { sendBtn.disabled = false; sendBtn.removeAttribute('disabled'); } // 延迟一点点击,确保 Vue 已更新状态 setTimeout(() => { sendBtn.click(); showToast('已发送(' + clipboardText.length + ' 字)'); }, 200); } else { showToast('已填入输入框,但未找到发送按钮,请手动发送'); } } catch (e) { showToast('操作失败:' + e.message); log('读取剪切板并发送失败:', e); } }; // 按钮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, btnCopyBot, btnCopySel, btnPasteSend]; btnToggle.onclick = () => { expanded = !expanded; actionButtons.forEach(b => b.style.display = expanded ? 'block' : 'none'); btnToggle.textContent = expanded ? '🔓' : '🔒'; }; toolbar.appendChild(btnToggle); toolbar.appendChild(btnSave); toolbar.appendChild(btnCopyBot); toolbar.appendChild(btnCopySel); toolbar.appendChild(btnPasteSend); 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(); } })();