// ==UserScript== // @name 学习通绕过解锁粘贴限制 // @namespace https://github.com/suyu-ops/chaoxing-paste-unlock // @version 1.4.0 // @description 解锁学习通 代码编辑区、UE编辑器、讨论区 的粘贴限制,保留原生粘贴与图片上传 // @author suyu // @match *://*.chaoxing.com/* // @match *://mooc1-*.chaoxing.com/* // @exclude *://exam.chaoxing.com/* // @grant unsafeWindow // @run-at document-start // @tag 学习通 // @tag 粘贴限制 // @license CC BY-NC-SA 4.0 // @license https://creativecommons.org/licenses/by-nc-sa/4.0/ // @original-author Muyue // @original-license CC BY-NC-SA 4.0 // @original-script https://scriptcat.org/zh-CN/script-show-page/4835 // ==/UserScript== // // --------------------------------------------------------------- // 本脚本基于 @original-script 修改,沿用原作品 CC BY-NC-SA 4.0 许可。 // 非商业用途;再分发须保留本段及同一许可。 // 修改者:suyu // --------------------------------------------------------------- // // ---------------------------- 1.4.0 ---------------------------- // 适配 // · CodeMirror 5 / CodeMirror 6 / UEditor 三套编辑器统一处理 // · 主文档与同源 iframe 自动扫描,SPA 路由切换后自动重新挂载 // // 粘贴行为 // · 不接管图片与附件:剪贴板含文件时直接放行,交给平台原生的上传链路 // · 纯文本按编辑器类型分别处理: // CM5 捕获阶段记录,交给 CodeMirror 原生插入 // CM6 直接 dispatch,标记为普通输入事件 // UE 优先走 execCommand,失败则按 enterTag 生成段落后插入 DOM // · 站点在 beforepaste 里清空内容时,按调用前后的差异还原 // // 稳定性 // · UEditor 未 ready 时排队,ready 回调只注册一次,避免重复插入 // · 补插只发生在文档确实没有变化时,位置取粘贴瞬间的选区快照 // · UEditor 监听器原地包装,不改变数组长度,避免同一事件的其他监听被跳过 // · 选区在失焦前记录,插入前恢复,避免内容落到文首 // · 插入后同步编辑器状态,页面能感知到内容变化 // // 其它 // · 单一轮询,页面隐藏时暂停,长时间无命中自动降频 // · 剪贴板零宽字符写入在粘贴窗口内被忽略 // · 讨论区只解除粘贴开关,不修改任何身份字段 // --------------------------------------------------------------- (function () { 'use strict'; var w = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; var TAG = '[Unified-Paste]'; var MARK = '只能录入不能粘贴'; var NOTICE = '已绕过粘贴限制 By.suyu'; var CM_SEL = '.CodeMirror, .cm-editor, .cm-content, .cm-scroller, .cm-line'; function log() { try { console.log.apply(console, [TAG].concat([].slice.call(arguments))); } catch (e) {} } try { if (w.__xxtPasteUnlock) return; w.__xxtPasteUnlock = '1.4.0'; } catch (e) {} function doc() { try { return w.document || document; } catch (e) { return document; } } /* ---------------- toast ---------------- */ var noticeAt = {}; function showNotice(msg) { var now = Date.now(); if (now - (noticeAt[msg] || 0) < 3000) return; noticeAt[msg] = now; try { if (w.$ && typeof w.$.toast === 'function') { w.$.toast({ type: 'notice', content: msg }); return; } } catch (e) {} log(msg); } /* ---------------- 文本工具 ---------------- */ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } // 块级闭合补 \n + textarea(RCDATA) 解实体。不建 DOM、不执行脚本、不加载图片。 function htmlToText(html) { try { var s = String(html) .replace(/<\s*br\s*\/?>/gi, '\n') .replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\s*>/gi, '\n') .replace(/<[^>]*>/g, ''); var ta = doc().createElement('textarea'); ta.innerHTML = s; return ta.value .replace(/\u00a0/g, ' ') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/^\n+|\n+$/g, ''); } catch (e) { return ''; } } function textToUeHtml(text, enterTag) { var lines = String(text).replace(/\r\n?/g, '\n').split('\n'); if (enterTag === 'br') return lines.map(esc).join('
'); return lines.map(function (l) { return '

' + (l ? esc(l) : '
') + '

'; }).join(''); } function clipboardHasFile(cd) { if (!cd) return false; try { if (cd.items) for (var i = 0; i < cd.items.length; i++) if (cd.items[i].kind === 'file') return true; if (cd.files && cd.files.length) return true; if (cd.types) for (var j = 0; j < cd.types.length; j++) if (cd.types[j] === 'Files') return true; } catch (e) {} return false; } function clipboardText(cd) { var t = ''; try { t = cd.getData('text/plain') || cd.getData('Text') || ''; } catch (e) {} if (t) return t; try { var h = cd.getData('text/html'); if (h) return htmlToText(h); } catch (e) {} return ''; } /* ============================================================ * UEditor:行为判定,不信源码 * ============================================================ */ // 只用来"精确识别以便连 toast 一起干掉";识别失败也不影响功能(有行为兜底) function looksLikeBlocker(fn) { if (typeof fn !== 'function' || fn.__xxtPass) return false; try { var src = Function.prototype.toString.call(fn); if (src.indexOf(MARK) !== -1) return true; return /html\s*\.\s*html\s*=\s*['"]{2}/.test(src); } catch (e) { return false; } } // 调用监听器前后比对 html.html 快照;被清空则还原。 function guardBeforePaste(listener) { if (typeof listener !== 'function' || listener.__xxtGuard) return listener; var guarded = function (html) { var snapshot = html && html.html; var r; try { r = listener.apply(this, arguments); } catch (e) { r = undefined; } if (html && !html.html && snapshot) { html.html = snapshot; // 被清空 → 还原,内容照插 showNotice(NOTICE); } return r; }; guarded.__xxtGuard = true; guarded.__xxtOrig = listener; return guarded; } var passthrough = function () { return true; }; passthrough.__xxtPass = true; var editorPasteRef = passthrough; function applyEditorPaste(fn) { // 这个槽位按定义就是粘贴回调;站点挂上去的一律当拦截器(自研,不依赖文案) if (typeof fn === 'function' && !fn.__xxtPass) { editorPasteRef = passthrough; log('editorPaste slot neutralized', looksLikeBlocker(fn) ? '(identified)' : '(unidentified)'); return; } if (typeof fn === 'function') editorPasteRef = fn; } function wrapAddListener(owner) { if (!owner || typeof owner.addListener !== 'function' || owner.addListener.__xxt) return; var raw = owner.addListener; var wrapped = function (type, fn) { if ((type === 'beforepaste' || type === 'beforePaste') && typeof fn === 'function') { if (looksLikeBlocker(fn)) { // 认得出来:连 toast 一起干掉 log('dropped beforepaste', this && (this.key || this.uid)); return this; } return raw.call(this, type, guardBeforePaste(fn)); // 认不出:包行为兜底 } return raw.apply(this, arguments); }; wrapped.__xxt = true; try { owner.addListener = wrapped; } catch (e) {} } // 原地替换元素,不改变数组长度(UE 的 fireEvent 倒序遍历同一数组,删除会跳过相邻项) function guardEditor(editor) { if (!editor) return; wrapAddListener(editor); var all = editor.__allListeners; if (!all) return; var list = all.beforepaste; if (!list || !list.length) return; for (var i = 0; i < list.length; i++) { var fn = list[i]; if (typeof fn !== 'function') continue; if (looksLikeBlocker(fn)) { list[i] = null; // 留 null,fireEvent 里有 `if (!listeners[k]) continue` log('neutralized beforepaste listener', editor.key || editor.uid); } else if (!fn.__xxtGuard) { list[i] = guardBeforePaste(fn); } } } function enablePasteFile(editor) { var o = editor && editor.options; if (!o) return; if (o.disablePasteImage !== false) o.disablePasteImage = false; if (o.disableDraggable !== false) o.disableDraggable = false; } // 只在值真的是 true 时才写,避免每拍无条件改配置对象 function patchUeConfig() { try { var cfg = w.UEDITOR_CONFIG; if (!cfg) return; if (cfg.disablePasteImage !== false) cfg.disablePasteImage = false; if (cfg.disableDraggable !== false) cfg.disableDraggable = false; } catch (e) {} } /* ---- 选区记忆:UEditor 与 CodeMirror 都需要 ---- */ var ueRanges = new WeakMap(); function rememberUeRange(editor) { try { var sel = editor && editor.selection; var r = sel && typeof sel.getRange === 'function' ? sel.getRange() : null; if (r && typeof r.cloneRange === 'function') ueRanges.set(editor, r.cloneRange()); } catch (e) {} } function restoreUeRange(editor) { var r = ueRanges.get(editor); if (!r) return false; try { if (typeof r.select === 'function') { r.select(); return true; } } catch (e) {} return false; } function bindUeSelection(editor, d) { if (d.__xxtSelBound) return; d.__xxtSelBound = true; ['mouseup', 'keyup', 'selectionchange'].forEach(function (t) { try { d.addEventListener(t, function () { rememberUeRange(editor); }, true); } catch (e) {} }); try { editor.addListener('focus', function () { rememberUeRange(editor); }); } catch (e) {} } /* ---- 插入后通知平台 ---- */ function notifyUeChanged(editor, d) { // UE 的 execCommand 内部会 fireEvent('contentchange'),但页面的 answerContentChange() // 只挂在 contentChange 监听上、UE 不会替我们调;不显式通知,离开页面会弹"可能不会保存"。 try { editor.fireEvent('contentchange'); } catch (e) {} try { editor.fireEvent('contentChange'); } catch (e) {} try { if (typeof w.answerContentChange === 'function') w.answerContentChange(); } catch (e) {} try { if (d) d.dispatchEvent(new w.Event('selectionchange')); w.dispatchEvent(new w.Event('resize')); } catch (e) {} } /* ---- 插入 ---- */ function ueBodyLen(editor) { try { return (editor.body && (editor.body.innerHTML || '').length) || 0; } catch (e) { return 0; } } function ueInsertAtRange(editor, text) { try { var body = editor.body; var d = body.ownerDocument; var sel = d.getSelection(); var html = textToUeHtml(text, (editor.options && editor.options.enterTag) || 'p'); var box = d.createElement('div'); box.innerHTML = html; var frag = d.createDocumentFragment(); while (box.firstChild) frag.appendChild(box.firstChild); if (sel && sel.rangeCount && sel.anchorNode && body.contains(sel.anchorNode)) { var r = sel.getRangeAt(0); r.deleteContents(); r.insertNode(frag); sel.collapseToEnd(); } else { body.appendChild(frag); } notifyUeChanged(editor, d); return true; } catch (e) { log('ue range insert failed', e); return false; } } // ready 未完成时排队;ready 回调只注册一次,避免连点触发重复插入 var ueQueues = new WeakMap(); // editor -> { pending: [text], asked: bool } function ueInsertNow(editor, text) { try { editor.focus(); } catch (e) {} restoreUeRange(editor); var enter = (editor.options && editor.options.enterTag) || 'p'; var html = textToUeHtml(text, enter); var before = ueBodyLen(editor); // 只用一个名字:UEditor 的 execCommand 内部会 cmdName.toLowerCase(), // 'insertHtml' 与 'inserthtml' 是同一个命令,试两个等于执行两次。 try { editor.execCommand('inserthtml', html); } catch (e) { log('inserthtml threw', e); } if (ueBodyLen(editor) !== before) { notifyUeChanged(editor, editor.body && editor.body.ownerDocument); return true; } log('inserthtml no-op, fallback to range insert'); return ueInsertAtRange(editor, text); } function ueIsReady(editor) { try { // isReady 存在时以它为准:UE 在 ready 之前 body 可能已经有 ownerDocument, // 但 execCommand 仍会被 _beforeExecCommand 丢弃。 if (editor.isReady === true) return true; if (editor.isReady === false) return false; // 只有 isReady 完全没定义(老版本 UE)才退回 body 判定 return !!(editor.body && editor.body.ownerDocument && editor.body.firstChild !== undefined); } catch (e) { return false; } } function ueInsert(editor, text) { if (ueIsReady(editor)) { ueInsertNow(editor, text); return; } var rec = ueQueues.get(editor); if (rec) { rec.pending.push(text); return; } // 已在等 ready,只入队 rec = { pending: [text] }; ueQueues.set(editor, rec); var flush = function () { ueQueues.delete(editor); var items = rec.pending; rec.pending = []; for (var i = 0; i < items.length; i++) { try { ueInsertNow(editor, items[i]); } catch (e) { log('queued insert failed', e); } } }; try { editor.ready(flush); } catch (e) { ueQueues.delete(editor); ueInsertNow(editor, text); } } // 兜底:不抢原生粘贴,只在"粘完发现内容没变"时补一刀 function bindUePaste(editor, d) { bindUeSelection(editor, d); d.addEventListener('paste', function (e) { var cd = e.clipboardData || w.clipboardData; if (!cd) return; if (clipboardHasFile(cd)) return; var text = clipboardText(cd); if (!text) return; var before = ueBodyLen(editor), beforeText = ''; try { beforeText = editor.body ? editor.body.textContent : ''; } catch (err) {} setTimeout(function () { var nowText = ''; try { nowText = editor.body ? editor.body.textContent : ''; } catch (err) {} if (ueBodyLen(editor) !== before || nowText !== beforeText) return; log('paste swallowed by page, inserting manually'); ueInsert(editor, text); showNotice(NOTICE); }, 0); }, true); } /* ============================================================ * CodeMirror 5 * 不在 beforeChange 里改文档;只把站点 cancel 掉的那次 change 复活。 * ============================================================ */ function hookCM5(cm) { if (!cm || cm.__xxt5) return; var wrap = null; try { wrap = cm.getWrapperElement ? cm.getWrapperElement() : null; } catch (e) {} if (!wrap) return; cm.__xxt5 = true; // 捕获阶段只记录:剪贴板原文 + 当前选区快照(CM5 的选区在失焦后同样会退化) wrap.addEventListener('paste', function (e) { var cd = e.clipboardData || w.clipboardData; if (!cd) return; if (clipboardHasFile(cd)) return; var text = clipboardText(cd); if (!text) return; try { cm.__xxtPasteText = text; cm.__xxtPasteAt = Date.now(); cm.__xxtFrom = cm.getCursor('from'); cm.__xxtTo = cm.getCursor('to'); cm.__xxtBase = cm.getValue(); } catch (err) {} }, true); cm.on('beforeChange', function (c, change) { if (!change || change.origin !== 'paste') return; var fresh = Date.now() - (c.__xxtPasteAt || 0) < 1500; var stashed = fresh ? (c.__xxtPasteText || '') : ''; var text = (change.text || []).join('\n'); var want = stashed || text; if (!want) return; var canceled = change.canceled === true; var emptied = !text; if (canceled || (emptied && stashed)) { change.canceled = false; if (emptied && stashed && typeof change.update === 'function') { change.update(undefined, undefined, stashed.split('\n')); } showNotice(NOTICE); } else { showNotice(NOTICE); } // 只有确认文档完全没变才补插;位置取粘贴瞬间的快照, // 不用 change.from/to(CM 可能已应用过一次,那两个位置已过期)。 var from = c.__xxtFrom, to = c.__xxtTo; setTimeout(function () { var nowValue = null, base = c.__xxtBase; try { nowValue = c.getValue(); } catch (e) { return; } if (base === undefined || base === null) return; if (nowValue !== base) { c.__xxtBase = nowValue; return; } // 已经插入 try { c.replaceRange(want, from || change.from, to || change.to, '+input'); c.focus(); try { c.__xxtBase = c.getValue(); } catch (e) {} showNotice(NOTICE); } catch (e) { log('cm5 deferred insert failed', e); } }, 0); }); } /* ============================================================ * CodeMirror 6 * ============================================================ */ function viewFromCM6(host) { try { var content = (host.classList && host.classList.contains('cm-content')) ? host : host.querySelector('.cm-content'); var cv = content && content.cmView; var v = cv && cv.rootView && (cv.rootView.view || cv.view); if (v && v.state && typeof v.dispatch === 'function') return v; } catch (e) {} return null; } function hookCM6(view) { var host = view.dom || view.contentDOM; if (!host || host.__xxt6) return; host.__xxt6 = true; host.addEventListener('paste', function (e) { var cd = e.clipboardData || w.clipboardData; if (!cd) return; if (clipboardHasFile(cd)) return; var text = clipboardText(cd); if (!text) return; e.preventDefault(); e.stopImmediatePropagation(); // CM6 的 handler 挂在 contentDOM(子节点),捕获阶段先到 try { var sel = view.state.selection.main; view.dispatch({ changes: { from: sel.from, to: sel.to, insert: text }, selection: { anchor: sel.from + text.length }, // input.type 而非 input.paste:绕开站点按 input.paste 设的 transactionFilter userEvent: 'input.type', scrollIntoView: true }); if (typeof view.focus === 'function') view.focus(); showNotice(NOTICE); } catch (err) { log('cm6 dispatch failed:', err); } }, true); } /* ============================================================ * 剪贴板写入防护 * 页面可能写入零宽字符污染剪贴板,这里只在粘贴窗口内忽略这类写入 * ============================================================ */ var ZERO_WIDTH = /^[\s\u200b\u200c\u200d\u2060\ufeff]*$/; var lastPasteAt = 0; function patchClipboardWrite() { try { var clip = w.navigator && w.navigator.clipboard; if (!clip || clip.__xxt || typeof clip.writeText !== 'function') return; var orig = clip.writeText.bind(clip); clip.writeText = function (text) { try { if (typeof text === 'string' && ZERO_WIDTH.test(text)) { // 只在"刚发生过粘贴"的 8s 窗口内拦,避免影响页面正常的空剪贴板操作 if (Date.now() - lastPasteAt < 8000) { log('blocked pollution writeText()', JSON.stringify(text)); return Promise.resolve(); } } } catch (e) {} return orig(text); }; clip.__xxt = true; } catch (e) {} } /* ============================================================ * 内联 on* 清理:存在才写,避免无谓的属性变更 * ============================================================ */ var INLINE_EVENTS = ['paste', 'copy', 'cut', 'contextmenu', 'selectstart', 'dragstart']; function clearInlineHandlers(d) { if (!d) return; var targets = [d]; if (d.body) targets.push(d.body); if (d.documentElement) targets.push(d.documentElement); for (var i = 0; i < targets.length; i++) { for (var j = 0; j < INLINE_EVENTS.length; j++) { var k = 'on' + INLINE_EVENTS[j]; try { if (targets[i][k]) targets[i][k] = null; } catch (e) {} } } } /* ============================================================ * 讨论区 * ============================================================ */ function initDiscussion() { function setCfg() { var o = w.obj; if (!o) return; try { if (o.circle && typeof o.circle === 'object' && o.circle.prohibitReplyPasting !== 0) { o.circle.prohibitReplyPasting = 0; } if (o.prohibitReplyPasting !== undefined && o.prohibitReplyPasting !== 0) { o.prohibitReplyPasting = 0; } // 不写 o.isManager:越权会让页面弹管理按钮、服务端复核时常返 403 } catch (e) {} } w.addEventListener('paste', function (e) { lastPasteAt = Date.now(); var t = e.target; if (!t) return; var editable = t.tagName === 'TEXTAREA' || t.isContentEditable === true; if (!editable) return; if (typeof t.closest === 'function' && t.closest(CM_SEL)) return; // 放过代码编辑器 e.stopImmediatePropagation(); // 只停传播,不 preventDefault }, true); return setCfg; } /* ============================================================ * 扫描 * ============================================================ */ function scanUEditor() { var UE = w.UE; if (!UE || !UE.instants) return 0; wrapAddListener(UE.Editor && UE.Editor.prototype); var n = 0, keys; try { keys = Object.keys(UE.instants); } catch (e) { return 0; } for (var i = 0; i < keys.length; i++) { var key = keys[i]; try { var editor = UE.instants[key]; if (!editor) continue; guardEditor(editor); enablePasteFile(editor); var d = null; try { d = editor.iframe && editor.iframe.contentDocument; } catch (e) { d = null; } if (!d) continue; if (!d.__xxtPasteHooked) { d.__xxtPasteHooked = true; bindUePaste(editor, d); clearInlineHandlers(d); log('UEditor paste unlocked:', key); } n++; } catch (e) { log('ue scan error:', key, e); } } return n; } function scanCM(root) { if (!root || !root.querySelectorAll) return 0; var n = 0; try { root.querySelectorAll('.CodeMirror').forEach(function (wrap) { if (wrap.CodeMirror) { hookCM5(wrap.CodeMirror); n++; } }); root.querySelectorAll('.cm-editor, .cm-content').forEach(function (host) { var v = viewFromCM6(host); if (v) { hookCM6(v); n++; } }); } catch (e) { log('scanCM error', e); } return n; } function scanAll() { var n = 0; try { n += scanUEditor(); } catch (e) { log('ue scan failed', e); } n += scanCM(doc()); try { doc().querySelectorAll('iframe').forEach(function (f) { var d = null; try { d = f.contentDocument; } catch (e) { return; } if (d) n += scanCM(d); }); } catch (e) {} var ce = w.codeEditors; if (ce && typeof ce === 'object') { for (var id in ce) { var ed = ce[id]; if (ed && typeof ed.on === 'function') { hookCM5(ed); n++; } } } return n; } /* ============================================================ * 单 ticker * ============================================================ */ var timer = null, dirty = true, lastHit = 0, lastScan = 0, setCfg = null; function tick() { var d = doc(); if (!d || d.hidden) return; patchUeConfig(); var now = Date.now(); var idle = now - lastHit > 3000 ? 2500 : 600; if (!dirty && now - lastScan < idle) return; dirty = false; lastScan = now; try { if (scanAll() > 0) lastHit = now; if (setCfg) setCfg(); } catch (e) { log('tick error:', e); } } function startTimer() { if (!timer) timer = setInterval(tick, 600); } /* ---- document-start 立即安装 ---- */ try { applyEditorPaste(w.editorPaste); Object.defineProperty(w, 'editorPaste', { configurable: true, enumerable: true, get: function () { return editorPasteRef; }, set: applyEditorPaste }); } catch (e) { try { w.editorPaste = passthrough; } catch (e2) {} } patchUeConfig(); patchClipboardWrite(); try { var ueRef = w.UE; Object.defineProperty(w, 'UE', { configurable: true, enumerable: true, get: function () { return ueRef; }, set: function (v) { ueRef = v; dirty = true; } }); } catch (e) {} /* ---- 启动 ---- */ function boot() { var d = doc(); setCfg = initDiscussion(); clearInlineHandlers(d); try { new MutationObserver(function () { dirty = true; }) .observe(d.documentElement || d, { childList: true, subtree: true }); } catch (e) {} d.addEventListener('visibilitychange', function () { if (d.hidden) { clearInterval(timer); timer = null; } else { startTimer(); tick(); } }); startTimer(); tick(); } if (doc().readyState === 'loading') { doc().addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();