使用 Greasemonkey 解除网页复制粘贴限制

本文最后更新于:2019年10月16日 凌晨

使用 Greasemonkey 解除网页复制粘贴限制

吾辈发布了一个油猴脚本,可以直接安装 解除网页限制 以获得更好的使用体验。

场景

在浏览网页时经常会出现的一件事,当吾辈想要复制,突然发现复制好像没用了?(知乎禁止转载的文章)亦或者是复制的最后多出了一点内容(简书),或者干脆直接不能选中了(360doc)。粘贴时也有可能发现一直粘贴不了(支付宝登录)。

问题

欲先制敌,必先惑敌。想要解除复制粘贴的限制,就必须要清楚它们是如何实现的。不管如何,浏览器上能够运行的都是 JavaScript,它们都是使用 JavaScript 实现的。实现方式大致都是监听相应的事件(例如 onkeydown 监听 Ctrl-C),然后做一些特别的操作。

例如屏蔽复制功能只需要一句代码

document.oncopy = event => false

是的,只要返回了 false,那么 copy 就会失效。还有一个更讨厌的方式,直接在 body 元素上加行内事件

<body oncopy="javascript: return false" />

解决

可以看出,一般都是使用 JavaScript 在相应事件中返回 false,来阻止对应事件。那么,既然事件都被阻止了,是否意味着我们就束手无策了呢?吾辈所能想到的解决方案大致有三种方向

  • 使用 JavaScript 监听事件并自行实现复制/剪切/粘贴功能
    • 优点:实现完成后不管是任何网站都能使用,并且不会影响到监听之外的事件,也不会删除监听的同类型事件,可以解除浏览器本身的限制(密码框禁止复制)
    • 缺点:某些功能自行实现难度很大,例如选择文本
  • 重新实现 addEventListener 然后删除掉网站自定义的事件
    • 优点:事件生效范围广泛,通用性高,不仅 _复制/剪切/粘贴_,其他类型的事件也可以解除
    • 缺点:实现起来需要替换 addEventListener 事件够早,对浏览器默认操作不会生效(密码框禁止复制),而且某些网站也无法破解
  • 替换元素并删除 DOM 上的事件属性
    • 优点:能够确保网站 js 的限制被解除,通用性高,事件生效范围广泛
    • 缺点:可能影响到其他类型的事件,复制节点时不会复制使用 addEventListener 添加的事件

      注:此方法不予演示,缺陷实在过大

总之,如果真的想解除限制,恐怕需要两种方式并用才可以呢

使用 JavaScript 监听事件并自行实现复制/剪切/粘贴功能

实现强制复制

思路

  1. 冒泡监听 copy 事件
  2. 获取当前选中的内容
  3. 设置剪切版的内容
  4. 阻止默认事件处理
// 强制复制
document.addEventListener(
  'copy',
  event => {
    event.clipboardData.setData(
      'text/plain',
      document.getSelection().toString(),
    )
    // 阻止默认的事件处理
    event.preventDefault()
  },
  true,
)

实现强制剪切

思路

  1. 冒泡监听 cut 事件
  2. 获取当前选中的内容
  3. 设置剪切版的内容
  4. 如果是可编辑内容要删除选中部分
  5. 阻止默认事件处理

可以看到唯一需要增加的就是需要额外处理可编辑内容了,然而代码量瞬间爆炸了哦

/**
 * 字符串安全的转换为小写
 * @param {String} str 字符串
 * @returns {String} 转换后得到的全小写字符串
 */
function toLowerCase(str) {
  if (!str || typeof str !== 'string') {
    return str
  }
  return str.toLowerCase()
}

/**
 * 判断指定元素是否是可编辑元素
 * 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
 * @param {Element} el 需要进行判断的元素
 * @returns {Boolean} 是否为可编辑元素
 */
function isEditable(el) {
  var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
  return (
    el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
  )
}

/**
 * 获取输入框中光标所在位置
 * @param  {Element} el 需要获取的输入框元素
 * @returns {Number} 光标所在位置的下标
 */
function getCusorPostion(el) {
  return el.selectionStart
}

/**
 * 设置输入框中选中的文本/光标所在位置
 * @param {Element} el 需要设置的输入框元素
 * @param {Number} start 光标所在位置的下标
 * @param {Number} {end} 结束位置,默认为输入框结束
 */
function setCusorPostion(el, start, end = start) {
  el.focus()
  el.setSelectionRange(start, end)
}

/**
 * 在指定范围内删除文本
 * @param {Element} el 需要设置的输入框元素
 * @param {Number} {start} 开始位置,默认为当前选中开始位置
 * @param {Number} {end} 结束位置,默认为当前选中结束位置
 */
function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
  // 删除之前必须要 [记住] 当前光标的位置
  var index = getCusorPostion(el)
  var value = el.value
  el.value = value.substr(0, start) + value.substr(end, value.length)
  setCusorPostion(el, index)
}

// 强制剪切
document.addEventListener(
  'cut',
  event => {
    event.clipboardData.setData(
      'text/plain',
      document.getSelection().toString(),
    )
    // 如果是可编辑元素还要进行删除
    if (isEditable(event.target)) {
      removeText(event.target)
    }
    event.preventDefault()
  },
  true,
)

实现强制粘贴

  1. 冒泡监听 focus/blur,以获得最后一个获得焦点的可编辑元素
  2. 冒泡监听 paste 事件
  3. 获取剪切版的内容
  4. 获取最后一个获得焦点的可编辑元素
  5. 删除当前选中的文本
  6. 在当前光标处插入文本
  7. 阻止默认事件处理
/**
 * 获取到最后一个获得焦点的元素
 */
var getLastFocus = (lastFocusEl => {
  document.addEventListener(
    'focus',
    event => {
      lastFocusEl = event.target
    },
    true,
  )
  document.addEventListener(
    'blur',
    event => {
      lastFocusEl = null
    },
    true,
  )
  return () => lastFocusEl
})(null)

/**
 * 字符串安全的转换为小写
 * @param {String} str 字符串
 * @returns {String} 转换后得到的全小写字符串
 */
function toLowerCase(str) {
  if (!str || typeof str !== 'string') {
    return str
  }
  return str.toLowerCase()
}

/**
 * 判断指定元素是否是可编辑元素
 * 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
 * @param {Element} el 需要进行判断的元素
 * @returns {Boolean} 是否为可编辑元素
 */
function isEditable(el) {
  var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
  return (
    el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
  )
}

/**
 * 获取输入框中光标所在位置
 * @param  {Element} el 需要获取的输入框元素
 * @returns {Number} 光标所在位置的下标
 */
function getCusorPostion(el) {
  return el.selectionStart
}

/**
 * 设置输入框中选中的文本/光标所在位置
 * @param {Element} el 需要设置的输入框元素
 * @param {Number} start 光标所在位置的下标
 * @param {Number} {end} 结束位置,默认为输入框结束
 */
function setCusorPostion(el, start, end = start) {
  el.focus()
  el.setSelectionRange(start, end)
}

/**
 * 在指定位置后插入文本
 * @param {Element} el 需要设置的输入框元素
 * @param {String} value 要插入的值
 * @param {Number} {start} 开始位置,默认为当前光标处
 */
function insertText(el, text, start = getCusorPostion(el)) {
  var value = el.value
  el.value = value.substr(0, start) + text + value.substr(start)
  setCusorPostion(el, start + text.length)
}

/**
 * 在指定范围内删除文本
 * @param {Element} el 需要设置的输入框元素
 * @param {Number} {start} 开始位置,默认为当前选中开始位置
 * @param {Number} {end} 结束位置,默认为当前选中结束位置
 */
function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
  // 删除之前必须要 [记住] 当前光标的位置
  var index = getCusorPostion(el)
  var value = el.value
  el.value = value.substr(0, start) + value.substr(end, value.length)
  setCusorPostion(el, index)
}

// 强制粘贴
document.addEventListener(
  'paste',
  event => {
    // 获取当前剪切板内容
    var clipboardData = event.clipboardData
    var items = clipboardData.items
    var item = items[0]
    if (item.kind !== 'string') {
      return
    }
    var text = clipboardData.getData(item.type)
    // 获取当前焦点元素
    // 粘贴的时候获取不到焦点?
    var focusEl = getLastFocus()
    // input 居然不是 [可编辑] 的元素?
    if (isEditable(focusEl)) {
      removeText(focusEl)
      insertText(focusEl, text)
      event.preventDefault()
    }
  },
  true,
)

总结

脚本全貌

;(function() {
  'use strict'

  /**
   * 两种思路:
   * 1. 自己实现
   * 2. 替换元素
   */

  /**
   * 获取到最后一个获得焦点的元素
   */
  var getLastFocus = (lastFocusEl => {
    document.addEventListener(
      'focus',
      event => {
        lastFocusEl = event.target
      },
      true,
    )
    document.addEventListener(
      'blur',
      event => {
        lastFocusEl = null
      },
      true,
    )
    return () => lastFocusEl
  })(null)

  /**
   * 字符串安全的转换为小写
   * @param {String} str 字符串
   * @returns {String} 转换后得到的全小写字符串
   */
  function toLowerCase(str) {
    if (!str || typeof str !== 'string') {
      return str
    }
    return str.toLowerCase()
  }

  /**
   * 字符串安全的转换为大写
   * @param {String} str 字符串
   * @returns {String} 转换后得到的全大写字符串
   */
  function toUpperCase(str) {
    if (!str || typeof str !== 'string') {
      return str
    }
    return str.toUpperCase()
  }

  /**
   * 判断指定元素是否是可编辑元素
   * 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
   * @param {Element} el 需要进行判断的元素
   * @returns {Boolean} 是否为可编辑元素
   */
  function isEditable(el) {
    var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
    return (
      el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
    )
  }

  /**
   * 获取输入框中光标所在位置
   * @param  {Element} el 需要获取的输入框元素
   * @returns {Number} 光标所在位置的下标
   */
  function getCusorPostion(el) {
    return el.selectionStart
  }

  /**
   * 设置输入框中选中的文本/光标所在位置
   * @param {Element} el 需要设置的输入框元素
   * @param {Number} start 光标所在位置的下标
   * @param {Number} {end} 结束位置,默认为输入框结束
   */
  function setCusorPostion(el, start, end = start) {
    el.focus()
    el.setSelectionRange(start, end)
  }

  /**
   * 在指定位置后插入文本
   * @param {Element} el 需要设置的输入框元素
   * @param {String} value 要插入的值
   * @param {Number} {start} 开始位置,默认为当前光标处
   */
  function insertText(el, text, start = getCusorPostion(el)) {
    var value = el.value
    el.value = value.substr(0, start) + text + value.substr(start)
    setCusorPostion(el, start + text.length)
  }

  /**
   * 在指定范围内删除文本
   * @param {Element} el 需要设置的输入框元素
   * @param {Number} {start} 开始位置,默认为当前选中开始位置
   * @param {Number} {end} 结束位置,默认为当前选中结束位置
   */
  function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
    // 删除之前必须要 [记住] 当前光标的位置
    var index = getCusorPostion(el)
    var value = el.value
    el.value = value.substr(0, start) + value.substr(end, value.length)
    setCusorPostion(el, index)
  }

  // 强制复制
  document.addEventListener(
    'copy',
    event => {
      event.clipboardData.setData(
        'text/plain',
        document.getSelection().toString(),
      )
      event.preventDefault()
    },
    true,
  )

  // 强制剪切
  document.addEventListener(
    'cut',
    event => {
      event.clipboardData.setData(
        'text/plain',
        document.getSelection().toString(),
      )
      // 如果是可编辑元素还要进行删除
      if (isEditable(event.target)) {
        removeText(event.target)
      }
      event.preventDefault()
    },
    true,
  )

  // 强制粘贴
  document.addEventListener(
    'paste',
    event => {
      // 获取当前剪切板内容
      var clipboardData = event.clipboardData
      var items = clipboardData.items
      var item = items[0]
      if (item.kind !== 'string') {
        return
      }
      var text = clipboardData.getData(item.type)
      // 获取当前焦点元素
      // 粘贴的时候获取不到焦点?
      var focusEl = getLastFocus()
      // input 居然不是 [可编辑] 的元素?
      if (isEditable(focusEl)) {
        removeText(focusEl)
        insertText(focusEl, text)
        event.preventDefault()
      }
    },
    true,
  )

  function selection() {
    var dom
    document.onmousedown = event => {
      dom = event.target
      // console.log('点击: ', dom)
      debugger
      console.log('光标所在处: ', getCusorPostion(dom))
    }
    document.onmousemove = event => {
      console.log('移动: ', dom)
    }
    document.onmouseup = event => {
      console.log('松开: ', dom)
    }
  }
})()

重新实现 addEventListener 然后删除掉网站自定义的事件

该实现来灵感来源自 https://greasyfork.org/en/scripts/41075,几乎完美实现了解除限制的功能

原理很简单,修改原型,重新实现 EventTargetdocuementaddEventListener 函数

// ==UserScript==
// @name         解除网页限制
// @namespace    http://github.com/rxliuli
// @version      1.0
// @description  破解禁止复制/剪切/粘贴/选择/右键菜单的网站
// @author       rxliuli
// @include      https://www.jianshu.com/*
// @grant        GM.getValue
// @grant        GM.setValue
// 这里的 @run-at 非常重要,设置在文档开始时就载入脚本
// @run-at       document-start
// ==/UserScript==

;(() => {
  /**
   * 监听所有的 addEventListener, removeEventListener 事件
   */
  var documentAddEventListener = document.addEventListener
  var eventTargetAddEventListener = EventTarget.prototype.addEventListener
  var documentRemoveEventListener = document.removeEventListener
  var eventTargetRemoveEventListener = EventTarget.prototype.removeEventListener
  var events = []

  /**
   * 用来保存监听到的事件信息
   */
  class Event {
    constructor(el, type, listener, useCapture) {
      this.el = el
      this.type = type
      this.listener = listener
      this.useCapture = useCapture
    }
  }

  /**
   * 自定义的添加事件监听函数
   * @param {String} type 事件类型
   * @param {EventListener} listener 事件监听函数
   * @param {Boolean} {useCapture} 是否需要捕获事件冒泡,默认为 false
   */
  function addEventListener(type, listener, useCapture = false) {
    var _this = this
    var $addEventListener =
      _this === document
        ? documentAddEventListener
        : eventTargetAddEventListener
    events.push(new Event(_this, type, listener, useCapture))
    $addEventListener.apply(this, arguments)
  }

  /**
   * 自定义的根据类型删除事件函数
   * 该方法会删除这个类型下面全部的监听函数,不管数量
   * @param {String} type 事件类型
   */
  function removeEventListenerByType(type) {
    var _this = this
    var $removeEventListener =
      _this === document
        ? documentRemoveEventListener
        : eventTargetRemoveEventListener
    var removeIndexs = events
      .map((e, i) => (e.el === _this || e.type === arguments[0] ? i : -1))
      .filter(i => i !== -1)
    removeIndexs.forEach(i => {
      var e = events[i]
      $removeEventListener.apply(e.el, [e.type, e.listener, e.useCapture])
    })
    removeIndexs.sort((a, b) => b - a).forEach(i => events.splice(i, 1))
  }

  function clearEvent() {
    var eventTypes = [
      'copy',
      'cut',
      'select',
      'contextmenu',
      'selectstart',
      'dragstart',
    ]
    document.querySelectorAll('*').forEach(el => {
      eventTypes.forEach(type => el.removeEventListenerByType(type))
    })
  }

  ;(function() {
    document.addEventListener = EventTarget.prototype.addEventListener = addEventListener
    document.removeEventListenerByType = EventTarget.prototype.removeEventListenerByType = removeEventListenerByType
  })()

  window.onload = function() {
    clearEvent()
  }
})()

最后,JavaScript hook 技巧是真的很多,果然写 Greasemonkey 脚本这方面用得很多呢 (๑>ᴗ<๑)