Greasy Fork is available in English.
默认关闭、按站点启用的移动端解除网页复制限制脚本,覆盖 CSS 限制、放通复制相关事件并处理 clipboardData 劫持。
// ==UserScript==
// @name Mobile Copy Unlocker
// @namespace https://codex.local/userscripts
// @version 1.0.0
// @description 默认关闭、按站点启用的移动端解除网页复制限制脚本,覆盖 CSS 限制、放通复制相关事件并处理 clipboardData 劫持。
// @match *://*/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_setClipboard
// @grant unsafeWindow
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_ID = 'tm-mobile-copy-unlocker';
const STORAGE_KEY = `${SCRIPT_ID}:site-rules:v1`;
const DATA_ATTR = 'data-copy-unlocker-active';
const PAGE_BRIDGE_KEY = '__copyUnlockerPageBridge__';
const OPT_OUT_SELECTOR = '[data-copy-unlocker-preserve]';
const STRATEGY_VERSION = '2026.03';
const PROTECTED_EVENTS = [
'copy',
'cut',
'contextmenu',
'selectstart',
'dragstart',
'beforecopy',
'beforecut',
'keydown',
];
const INLINE_EVENT_PROPS = [
'oncopy',
'oncut',
'oncontextmenu',
'onselectstart',
'ondragstart',
'onbeforecopy',
'onbeforecut',
'onkeydown',
];
const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const state = {
active: false,
siteKey: getSiteKey(),
lastSelectionText: '',
menuCommandIds: [],
disposers: [],
};
const STRATEGIES = [
{
id: 'style-layer',
install: installStyleLayer,
},
{
id: 'page-bridge',
install: installPageBridgeStrategy,
},
{
id: 'event-shield',
install: installEventShield,
},
];
void boot();
async function boot() {
if (isTopWindow()) {
await registerMenus();
}
if (await isEnabledForCurrentSite()) {
activateRuntime();
}
}
function getSiteKey() {
return String(location.host || location.hostname || 'unknown').toLowerCase();
}
function isTopWindow() {
try {
return window.top === window.self;
} catch (_error) {
return true;
}
}
function isPromiseLike(value) {
return Boolean(value) && typeof value.then === 'function';
}
async function gmGetValue(key, fallbackValue) {
if (typeof GM_getValue !== 'function') {
return fallbackValue;
}
try {
const result = GM_getValue(key, fallbackValue);
return isPromiseLike(result) ? await result : result;
} catch (_error) {
return fallbackValue;
}
}
async function gmSetValue(key, value) {
if (typeof GM_setValue !== 'function') {
return;
}
const result = GM_setValue(key, value);
if (isPromiseLike(result)) {
await result;
}
}
async function loadSiteRules() {
const stored = await gmGetValue(STORAGE_KEY, {});
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
return {};
}
return stored;
}
async function saveSiteRules(rules) {
await gmSetValue(STORAGE_KEY, rules);
}
async function isEnabledForCurrentSite() {
const rules = await loadSiteRules();
return Boolean(rules[state.siteKey] && rules[state.siteKey].enabled);
}
async function setCurrentSiteEnabled(enabled) {
const rules = await loadSiteRules();
if (enabled) {
rules[state.siteKey] = {
enabled: true,
updatedAt: Date.now(),
strategyVersion: STRATEGY_VERSION,
};
} else {
delete rules[state.siteKey];
}
await saveSiteRules(rules);
}
async function registerMenus() {
unregisterMenus();
const enabled = await isEnabledForCurrentSite();
const hostLabel = state.siteKey;
const toggleLabel = enabled
? `关闭当前站点复制解锁: ${hostLabel}`
: `开启当前站点复制解锁: ${hostLabel}`;
state.menuCommandIds.push(
GM_registerMenuCommand(toggleLabel, async () => {
const nextEnabled = !(await isEnabledForCurrentSite());
await setCurrentSiteEnabled(nextEnabled);
if (nextEnabled) {
activateRuntime();
notifyUser(`复制解锁已启用: ${hostLabel}`);
} else {
deactivateRuntime();
notifyUser(`复制解锁已关闭: ${hostLabel}`);
}
await registerMenus();
})
);
state.menuCommandIds.push(
GM_registerMenuCommand(`清除当前站点配置: ${hostLabel}`, async () => {
await setCurrentSiteEnabled(false);
deactivateRuntime();
notifyUser(`站点配置已清除: ${hostLabel}`);
await registerMenus();
})
);
state.menuCommandIds.push(
GM_registerMenuCommand(
`查看当前状态: ${enabled ? '已启用' : '未启用'} / ${hostLabel}`,
() => {
const summary = {
host: hostLabel,
enabled,
active: state.active,
strategyVersion: STRATEGY_VERSION,
};
console.info(`[${SCRIPT_ID}]`, summary);
notifyUser(`${hostLabel}: ${enabled ? '已启用' : '未启用'}`);
}
)
);
}
function unregisterMenus() {
if (typeof GM_unregisterMenuCommand !== 'function') {
state.menuCommandIds.length = 0;
return;
}
for (const menuId of state.menuCommandIds.splice(0)) {
try {
GM_unregisterMenuCommand(menuId);
} catch (_error) {
// Tampermonkey 某些版本会忽略未知 id,这里直接吞掉即可。
}
}
}
function activateRuntime() {
if (state.active) {
return;
}
state.active = true;
for (const strategy of STRATEGIES) {
try {
const disposer = strategy.install();
if (typeof disposer === 'function') {
state.disposers.push(disposer);
}
} catch (error) {
console.error(`[${SCRIPT_ID}] strategy failed: ${strategy.id}`, error);
}
}
rememberSelection();
console.info(`[${SCRIPT_ID}] activated for`, state.siteKey);
}
function deactivateRuntime() {
if (!state.active) {
return;
}
state.active = false;
state.lastSelectionText = '';
while (state.disposers.length > 0) {
const disposer = state.disposers.pop();
try {
disposer();
} catch (error) {
console.error(`[${SCRIPT_ID}] cleanup failed`, error);
}
}
try {
document.documentElement.removeAttribute(DATA_ATTR);
} catch (_error) {
// ignore
}
console.info(`[${SCRIPT_ID}] deactivated for`, state.siteKey);
}
function installStyleLayer() {
const style = document.createElement('style');
style.id = `${SCRIPT_ID}-style`;
style.textContent = `
html[${DATA_ATTR}="1"],
html[${DATA_ATTR}="1"] body,
html[${DATA_ATTR}="1"] *,
html[${DATA_ATTR}="1"] *::before,
html[${DATA_ATTR}="1"] *::after {
-webkit-user-select: text !important;
user-select: text !important;
-webkit-touch-callout: default !important;
}
html[${DATA_ATTR}="1"] input,
html[${DATA_ATTR}="1"] textarea,
html[${DATA_ATTR}="1"] [contenteditable=""],
html[${DATA_ATTR}="1"] [contenteditable="true"],
html[${DATA_ATTR}="1"] [contenteditable="plaintext-only"] {
-webkit-user-select: text !important;
user-select: text !important;
}
`;
appendToDocument(style);
setDocumentActive(true);
return () => {
style.remove();
setDocumentActive(false);
};
}
function installEventShield() {
const listenerBag = createListenerBag();
const targetPairs = [window, document];
for (const target of targetPairs) {
for (const eventType of PROTECTED_EVENTS) {
listenerBag.add(target, eventType, handleProtectedEvent, {
capture: true,
passive: false,
});
}
}
listenerBag.add(document, 'selectionchange', rememberSelection, {
capture: true,
passive: true,
});
listenerBag.add(document, 'keyup', rememberSelection, {
capture: true,
passive: true,
});
listenerBag.add(document, 'touchend', rememberSelection, {
capture: true,
passive: true,
});
return () => {
listenerBag.removeAll();
};
}
function installPageBridgeStrategy() {
const bridge = ensurePageBridge();
if (bridge && typeof bridge.install === 'function') {
bridge.install();
bridge.setActive(true);
}
return () => {
try {
if (bridge && typeof bridge.teardown === 'function') {
bridge.teardown();
}
} catch (error) {
console.error(`[${SCRIPT_ID}] page bridge teardown failed`, error);
}
};
}
function ensurePageBridge() {
if (PAGE_WINDOW[PAGE_BRIDGE_KEY]) {
return PAGE_WINDOW[PAGE_BRIDGE_KEY];
}
const script = document.createElement('script');
script.id = `${SCRIPT_ID}-page-bridge`;
script.textContent = `(() => {
const BRIDGE_KEY = ${JSON.stringify(PAGE_BRIDGE_KEY)};
if (window[BRIDGE_KEY]) {
return;
}
const dataAttr = ${JSON.stringify(DATA_ATTR)};
const protectedTypes = new Set(${JSON.stringify(PROTECTED_EVENTS)});
const inlineProps = ${JSON.stringify(INLINE_EVENT_PROPS)};
const wrappedRegistry = [];
const wrapperCache = new WeakMap();
const state = {
active: false,
installed: false,
originals: null,
inlineDescriptors: [],
};
function isActive() {
const root = document.documentElement;
return state.active && root && root.getAttribute(dataAttr) === '1';
}
function shouldGuard(type) {
return protectedTypes.has(String(type));
}
function getWrappedListener(type, listener) {
if (!shouldGuard(type) || listener == null) {
return listener;
}
const isFn = typeof listener === 'function';
const isObj = !isFn && typeof listener.handleEvent === 'function';
if (!isFn && !isObj) {
return listener;
}
let byType = wrapperCache.get(listener);
if (!byType) {
byType = new Map();
wrapperCache.set(listener, byType);
}
const cacheKey = String(type);
if (byType.has(cacheKey)) {
return byType.get(cacheKey);
}
const wrapped = isFn
? function (...args) {
if (isActive()) {
return undefined;
}
return listener.apply(this, args);
}
: {
handleEvent(...args) {
if (isActive()) {
return undefined;
}
return listener.handleEvent.apply(listener, args);
},
};
byType.set(cacheKey, wrapped);
return wrapped;
}
function patchInlineProp(proto, propName) {
if (!proto) {
return;
}
const descriptor = Object.getOwnPropertyDescriptor(proto, propName);
if (!descriptor || typeof descriptor.get !== 'function' || typeof descriptor.set !== 'function') {
return;
}
state.inlineDescriptors.push([proto, propName, descriptor]);
Object.defineProperty(proto, propName, {
configurable: true,
enumerable: descriptor.enumerable,
get: descriptor.get,
set(value) {
if (!value || !isActive()) {
return descriptor.set.call(this, value);
}
return descriptor.set.call(this, getWrappedListener(propName.slice(2), value));
},
});
}
function removeWrappedRegistration(type, listener, options) {
const wrapped = getWrappedListener(type, listener);
for (let index = wrappedRegistry.length - 1; index >= 0; index -= 1) {
const item = wrappedRegistry[index];
if (item.type === String(type) && item.original === listener && item.wrapped === wrapped && item.options === options) {
wrappedRegistry.splice(index, 1);
break;
}
}
}
window[BRIDGE_KEY] = {
install() {
if (state.installed) {
return;
}
state.originals = {
addEventListener: EventTarget.prototype.addEventListener,
removeEventListener: EventTarget.prototype.removeEventListener,
};
EventTarget.prototype.addEventListener = function (type, listener, options) {
const wrapped = getWrappedListener(type, listener);
if (wrapped !== listener) {
wrappedRegistry.push({
target: this,
type: String(type),
original: listener,
wrapped,
options,
});
}
return state.originals.addEventListener.call(this, type, wrapped, options);
};
EventTarget.prototype.removeEventListener = function (type, listener, options) {
removeWrappedRegistration(type, listener, options);
return state.originals.removeEventListener.call(this, type, getWrappedListener(type, listener), options);
};
patchInlineProp(Window.prototype, 'oncopy');
patchInlineProp(Window.prototype, 'oncut');
patchInlineProp(Window.prototype, 'oncontextmenu');
patchInlineProp(Window.prototype, 'onselectstart');
patchInlineProp(Window.prototype, 'ondragstart');
patchInlineProp(Window.prototype, 'onbeforecopy');
patchInlineProp(Window.prototype, 'onbeforecut');
patchInlineProp(Window.prototype, 'onkeydown');
patchInlineProp(Document.prototype, 'oncopy');
patchInlineProp(Document.prototype, 'oncut');
patchInlineProp(Document.prototype, 'oncontextmenu');
patchInlineProp(Document.prototype, 'onselectstart');
patchInlineProp(Document.prototype, 'ondragstart');
patchInlineProp(Document.prototype, 'onbeforecopy');
patchInlineProp(Document.prototype, 'onbeforecut');
patchInlineProp(Document.prototype, 'onkeydown');
patchInlineProp(HTMLElement.prototype, 'oncopy');
patchInlineProp(HTMLElement.prototype, 'oncut');
patchInlineProp(HTMLElement.prototype, 'oncontextmenu');
patchInlineProp(HTMLElement.prototype, 'onselectstart');
patchInlineProp(HTMLElement.prototype, 'ondragstart');
patchInlineProp(HTMLElement.prototype, 'onbeforecopy');
patchInlineProp(HTMLElement.prototype, 'onbeforecut');
patchInlineProp(HTMLElement.prototype, 'onkeydown');
if (typeof SVGElement !== 'undefined') {
patchInlineProp(SVGElement.prototype, 'oncopy');
patchInlineProp(SVGElement.prototype, 'oncut');
patchInlineProp(SVGElement.prototype, 'oncontextmenu');
patchInlineProp(SVGElement.prototype, 'onselectstart');
patchInlineProp(SVGElement.prototype, 'ondragstart');
patchInlineProp(SVGElement.prototype, 'onbeforecopy');
patchInlineProp(SVGElement.prototype, 'onbeforecut');
patchInlineProp(SVGElement.prototype, 'onkeydown');
}
state.installed = true;
},
setActive(value) {
state.active = Boolean(value);
},
teardown() {
if (!state.installed || !state.originals) {
delete window[BRIDGE_KEY];
return;
}
state.active = false;
for (const item of wrappedRegistry.splice(0)) {
try {
state.originals.removeEventListener.call(item.target, item.type, item.wrapped, item.options);
state.originals.addEventListener.call(item.target, item.type, item.original, item.options);
} catch (_error) {
// ignore stale targets
}
}
EventTarget.prototype.addEventListener = state.originals.addEventListener;
EventTarget.prototype.removeEventListener = state.originals.removeEventListener;
for (const [proto, propName, descriptor] of state.inlineDescriptors.splice(0)) {
try {
Object.defineProperty(proto, propName, descriptor);
} catch (_error) {
// ignore non-configurable descriptors on old engines
}
}
state.installed = false;
state.originals = null;
delete window[BRIDGE_KEY];
},
};
})();`;
appendToDocument(script);
script.remove();
return PAGE_WINDOW[PAGE_BRIDGE_KEY] || null;
}
function handleProtectedEvent(event) {
if (!state.active || !event || event.isTrusted === false) {
return;
}
const eventType = String(event.type || '');
if (!PROTECTED_EVENTS.includes(eventType)) {
return;
}
const target = event.target;
if (isPreservedRegion(target)) {
return;
}
if (eventType === 'keydown') {
if (!isCopyRelatedKeydown(event)) {
return;
}
stopSiteInterception(event);
return;
}
if (eventType === 'contextmenu' || eventType === 'selectstart' || eventType === 'dragstart') {
stopSiteInterception(event);
return;
}
if (eventType === 'beforecopy' || eventType === 'beforecut') {
stopSiteInterception(event);
return;
}
if (eventType === 'copy' || eventType === 'cut') {
const editableTarget = getEditableElement(target);
const selectedText = getSelectionText(editableTarget);
stopSiteInterception(event);
if (!editableTarget && selectedText) {
if (writeClipboardDataToEvent(event, selectedText)) {
event.preventDefault();
} else {
void writeClipboardFallback(selectedText, eventType);
}
}
if (eventType === 'copy' && editableTarget && selectedText) {
void writeClipboardFallback(selectedText, eventType, { bestEffortOnly: true });
}
}
}
function stopSiteInterception(event) {
try {
event.stopImmediatePropagation();
} catch (_error) {
// ignore
}
try {
event.stopPropagation();
} catch (_error) {
// ignore
}
}
function rememberSelection() {
if (!state.active) {
return;
}
state.lastSelectionText = getSelectionText() || state.lastSelectionText;
}
function getSelectionText(editableElement) {
const editable = editableElement || getEditableElement(document.activeElement);
if (editable) {
if (editable instanceof HTMLTextAreaElement) {
return editable.value.slice(editable.selectionStart || 0, editable.selectionEnd || 0);
}
if (editable instanceof HTMLInputElement) {
const supportedTypes = new Set(['text', 'search', 'url', 'tel', 'password', 'email', 'number']);
if (supportedTypes.has(editable.type)) {
return editable.value.slice(editable.selectionStart || 0, editable.selectionEnd || 0);
}
}
if (editable.isContentEditable) {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
}
const selection = window.getSelection();
const text = selection ? selection.toString() : '';
return text || state.lastSelectionText || '';
}
function writeClipboardDataToEvent(event, text) {
if (!event || !event.clipboardData || !text) {
return false;
}
try {
event.clipboardData.setData('text/plain', text);
return true;
} catch (_error) {
return false;
}
}
async function writeClipboardFallback(text, source, options = {}) {
if (!text) {
return false;
}
const bestEffortOnly = Boolean(options.bestEffortOnly);
if (typeof GM_setClipboard === 'function') {
try {
const result = GM_setClipboard(text, 'text');
if (isPromiseLike(result)) {
await result;
}
return true;
} catch (_error) {
// keep trying
}
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function' && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_error) {
// iOS Safari 与部分 Android WebView 经常因为权限模型失败,继续降级。
}
}
if (!bestEffortOnly) {
return legacyExecCopy(text, source);
}
return false;
}
function legacyExecCopy(text, source) {
if (typeof document.execCommand !== 'function' || !text) {
return false;
}
const activeElement = document.activeElement;
const selection = document.getSelection();
const ranges = [];
if (selection) {
for (let index = 0; index < selection.rangeCount; index += 1) {
ranges.push(selection.getRangeAt(index).cloneRange());
}
}
const buffer = document.createElement('textarea');
buffer.value = text;
buffer.setAttribute('readonly', 'readonly');
buffer.setAttribute('aria-hidden', 'true');
buffer.setAttribute('data-copy-unlocker-buffer', source || 'copy');
buffer.style.position = 'fixed';
buffer.style.top = '0';
buffer.style.left = '0';
buffer.style.width = '1px';
buffer.style.height = '1px';
buffer.style.opacity = '0';
buffer.style.pointerEvents = 'none';
buffer.style.fontSize = '16px';
appendToDocument(buffer);
try {
buffer.focus({ preventScroll: true });
} catch (_error) {
buffer.focus();
}
buffer.select();
buffer.setSelectionRange(0, text.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch (_error) {
copied = false;
}
buffer.remove();
if (selection) {
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}
if (activeElement && typeof activeElement.focus === 'function') {
try {
activeElement.focus({ preventScroll: true });
} catch (_error) {
activeElement.focus();
}
}
return copied;
}
function getEditableElement(node) {
const element = asElement(node);
if (!element) {
return null;
}
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
return element;
}
return element.closest('[contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]');
}
function asElement(node) {
if (!node) {
return null;
}
if (node instanceof Element) {
return node;
}
if (node.parentElement) {
return node.parentElement;
}
return null;
}
function isPreservedRegion(node) {
const element = asElement(node);
return Boolean(element && element.closest(OPT_OUT_SELECTOR));
}
function isCopyRelatedKeydown(event) {
if (!event || !(event.ctrlKey || event.metaKey)) {
return false;
}
const key = String(event.key || '').toLowerCase();
return key === 'c' || key === 'x' || key === 'insert';
}
function appendToDocument(node) {
const parent = document.head || document.documentElement || document.body;
if (parent) {
parent.appendChild(node);
return;
}
document.addEventListener(
'DOMContentLoaded',
() => {
const fallbackParent = document.head || document.documentElement || document.body;
if (fallbackParent && !node.isConnected) {
fallbackParent.appendChild(node);
}
},
{ once: true }
);
}
function setDocumentActive(active) {
const applyFlag = () => {
if (!document.documentElement) {
return false;
}
if (active) {
document.documentElement.setAttribute(DATA_ATTR, '1');
} else {
document.documentElement.removeAttribute(DATA_ATTR);
}
return true;
};
if (applyFlag()) {
return;
}
document.addEventListener(
'readystatechange',
() => {
applyFlag();
},
{ once: true }
);
}
function createListenerBag() {
const removers = [];
return {
add(target, type, listener, options) {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, listener, options);
removers.push(() => {
try {
target.removeEventListener(type, listener, options);
} catch (_error) {
// ignore
}
});
},
removeAll() {
while (removers.length > 0) {
const remove = removers.pop();
remove();
}
},
};
}
function notifyUser(message) {
console.info(`[${SCRIPT_ID}] ${message}`);
if (!isTopWindow()) {
return;
}
const toast = document.createElement('div');
toast.textContent = message;
toast.setAttribute('role', 'status');
toast.style.position = 'fixed';
toast.style.left = '50%';
toast.style.bottom = '24px';
toast.style.zIndex = '2147483647';
toast.style.maxWidth = 'calc(100vw - 32px)';
toast.style.padding = '10px 14px';
toast.style.borderRadius = '999px';
toast.style.transform = 'translateX(-50%)';
toast.style.background = 'rgba(15, 23, 42, 0.92)';
toast.style.color = '#ffffff';
toast.style.fontSize = '13px';
toast.style.lineHeight = '1.4';
toast.style.boxShadow = '0 10px 30px rgba(15, 23, 42, 0.28)';
toast.style.pointerEvents = 'none';
appendToDocument(toast);
window.setTimeout(() => {
toast.remove();
}, 1800);
}
})();