main.js

/**
 * @file DMS UserScripts Toolkit
 *
 * @author 稻米鼠
 * @version 0.0.3
 */
/**
 * @classdesc GreasyMonkey 脚本工具类
 *
 * 因脚本中一些基础功能函数需要复用,所以写了这个小的工具库。为对用户安全负责,并符合 GreasyFork 审核规则,代码未作压缩,仅用 Parcel 简单过了一下,一方面是为了用 babel 转码,另一方为后期分文件书写不同类别功能做准备。**这导致代码中注释未能精确对应,使用方法请依照此说明**。
 *
 * @export
 * @class Toolkit
 */
export class Toolkit {
  /**
   * @summary 当前库的版本号
   * @name version
   * @memberof Toolkit
   */
  version = '0.0.3'
  /**
   * @summary 当前是否处于 debug 状态。
   * @description **Debug 模式开启方法:** 手动为此脚本设置一个数据: `is_debug: 1`(在脚本设置页面中)。值可以任意,因为会当作字符串处理,所以不为空即为真。
   *
   * **注意:** 这需要 `GM_getValue` API 的支持
   * @memberof Toolkit
   */
  is_debug = false; // debug 状态
  /**
   * @summary 引入方法
   * @description GreasyFork 发布地址: https://greasyfork.org/zh-CN/scripts/408776
   *
   * 请依照说明,在脚本元数据部分引入对应的最新地址。此工具库仍在更新中,所以请注意保持更新。
   *
   * * 会在控制台输出当前工具库的名称+版本号徽章
   * * 如果在初始化时传入了 `GM_info` 接口,并可用,会输出当前脚本的名称+版本号徽章
   * * 框架中页面不输出,避免重复输出
   * @constructor
   * @param {object} [GM={}] 其中放入会用到的 GreasyMonkey Api 对象
   * @example const DMSTookit = new DMS_UserScripts.Toolkit({GM_info, GM_getValue})
   * @memberof Toolkit
   */
  constructor(GM={}) {
    // 获取需要用到的 API
    for(const key in GM){
      this[key] = GM[key]
    }
    // 设定是否是开发状态
    this.is_debug =
      this.GM_getValue && this.GM_getValue('is_debug', false) ? true : false;
    // 输出版本标记
    if (self != top) return // 框架中页面不输出,避免重复
    this.badge('DMS UserScripts Toolkit', this.version, 'https://script.izyx.xyz')
    if(this.GM_info && this.GM_info.script && this.GM_info.script.name && this.GM_info.script.version){
      this.badge(this.GM_info.script.name, this.GM_info.script.version, this.GM_info.script.description ? this.GM_info.script.description : '', { rightBGColor: '#71baeb' })
    }
    // Debug 时输出获得的 API
    const ApiKeys = Object.keys(this).filter(key=>/^GM_/.test(key))
    this.dblog.apply(
      this,
      ['Constructor - No. of Apis = ' + ApiKeys.length].concat(ApiKeys)
    );
  }
  /* ====== 输出相关 ====== */
  /**
   * @summary 日志输出
   * @description 用 `console.info` 方法输出信息。如多条内容,则以折叠组的形式输出。主要为了便于 debug。
   * @param {string} by 由信息来源标识
   * @param  {...any} args 输出内容,任意多个参数
   * @example
   * DMSTookit.info('Demo', 'Some thing want to print to console.')  // 仅一项需输出内容
   * DMSTookit.info('Demo', 'Some thing want to print to console.', 'And more.')  // 多项输出内容
   * @memberof Toolkit
   */
  info(by, ...args){
    if(args.length > 1){
      console.groupCollapsed(by+': ')
      for(const arg of args){
        console.info(arg)
      }
      console.groupEnd()
    }else if(args.length === 1){
      console.info(by+': ', args[0])
    }
  }
  /**
   * @summary Debug 输出,仅开发时输出
   * @description 用 `.info` 方法输出。但仅在 `.is_debug` 为真时输出。这样在发布版本时可不删除这些调试内容。参数同 {@link Toolkit#info}
   * @param {string} by 由信息来源标识
   * @param  {...any} args 输出内容,任意多个参数
   * @memberof Toolkit
   */
  dblog(...args){
    if(this.is_debug){
      this.info.apply(this, args)
    }
  }
  /**
   * @summary 徽章输出
   * @description 类似这种效果 ![](https://img.shields.io/badge/%E5%B7%A6%E4%BE%A7%E6%96%87%E5%AD%97-%E5%8F%B3%E4%BE%A7%E6%96%87%E5%AD%97-%23ffc107?style=flat&labelColor=555555), 但并不一致。具体可在引用此库,并正确初始化后看控制台输出。其实除了好看也没什么特别的用途。
   * @param {string} leftText 徽章左侧文字
   * @param {string} rightText 徽章右侧文字
   * @param {string} endText 徽章后描述
   * @param {object} options={} 徽章配置项
   * * options.leftBGColor 左侧文字背景色
   * * options.leftColor 左侧文字颜色
   * * options.rightBGColor 右侧文字背景色
   * * options.rightColor 右侧文字颜色
   * @example
   * DMSTookit.badge(
   *   'DMS UserScripts Toolkit',
   *   DMSTookit.version,
   *   'https://greasyfork.org/zh-CN/scripts/408776'
   * )
   * @memberof Toolkit
   */
  badge (leftText, rightText, endText, options={}) {
    const opt = Object.assign({
      type: 'log',
      leftBGColor: '#555555',
      leftColor: '#ededed',
      rightBGColor: '#ffc107',
      rightColor: '#262318',
    }, options)
    console[opt.type](
      '%c'+leftText+'%c'+rightText
      + (endText ? '%c\n'+endText : ''),

      'color: '+opt.leftColor+'; '
      +'background-color: '+opt.leftBGColor+'; '
      +'border-radius: 2px 0 0 2px;'
      +'padding: 0 5px',

      'color: '+opt.rightColor+'; '
      +'background-color: '+opt.rightBGColor+'; '
      +'border-radius: 0 2px 2px 0;'
      +'padding: 0 5px;',

      '',
    )
  }
  /* ====== 数据相关 ====== */
  /**
   * @summary 【私有】验证是否具有所需的 Api
   * @description 如无法获取此 API,且处于 debug 状态,则在控制台输出提示
   * @param {string} apiName API 的名称
   * @param {string} by='DMS_Toolkit' (可选)调用来源
   * @returns {boolean}
   * @memberof Toolkit
   * @private
   */
  hasGMApi(apiName, by='DMS_Toolkit'){
    if(this[apiName]) return true
    this.dblog(by+' - 未能获取 '+apiName+' 接口', '* 请检查初始化时是否传入对应接口', '* 脚本元数据中是否声明对该接口的使用')
    return false
  }
  /**
   * @summary 获取存储中的数据
   * @description 基础方法,当接口不可用时会直接抛出错误,不建议直接使用。提供如下语法糖:
   * * `getGMData` 将 `type` 指定为 `GM`,然后省略此参数
   * * `getLocalData` 将 `type` 指定为 `local`,然后省略此参数
   * * `getSessionData` 将 `type` 指定为 `session`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   * @param {any} defaultValue=false 默认值,可省略
   */
  getData (type, dataName, defaultValue=false){
    switch (type) {
      case 'GM':  // 脚本管理器同宫的全局存储
        if( !this.hasGMApi('GM_getValue', 'getData') ) {
          throw new Error("This API is not available.")
        }else{
          return this.GM_getValue(dataName, defaultValue)
        }
        break;
      case 'local': // localStorage
        if( !window.localStorage ) {
          throw new Error("This API is not available.")
        }else{
          if(!localStorage.getItem(dataName)){
            return defaultValue
          }else{
            return JSON.parse(localStorage.getItem(dataName))
          }
        }
        break;
      case 'session': // sessionStorage
        if( !window.sessionStorage ) {
          throw new Error("This API is not available.")
        }else{
          if(!sessionStorage.getItem(dataName)){
            return defaultValue
          }else{
            return JSON.parse(sessionStorage.getItem(dataName))
          }
        }
        break;
      case 'cookie':  // cookies
        this.dblog('getData', 'The method of cookie operation is not yet available.')
        break;
      default:
        this.dblog('getData', 'Probably set the wrong type of get method.')
        throw new Error("Probably set the wrong type of get method.")
        break;
    }
  }
  getGMData (...args){
    try {
      return this.getData.apply(this, ['GM'].concat(args))
    } catch (error) {
      this.dblog('getGMData', error.message)
    }
  }
  getLocalData (...args){
    try {
      return this.getData.apply(this, ['local'].concat(args))
    } catch (error) {
      this.dblog('getLocalData', error.message)
    }
  }
  getSessionData (...args){
    try {
      return this.getData.apply(this, ['session'].concat(args))
    } catch (error) {
      this.dblog('getSessionData', error.message)
    }
  }
  /**
   * @summary 设置存储中的数据
   * @description 基础方法,当接口不可用时会直接抛出错误,不建议直接使用。提供如下语法糖:
   * * `setGMData` 将 `type` 指定为 `GM`,然后省略此参数
   * * `setLocalData` 将 `type` 指定为 `local`,然后省略此参数
   * * `setSessionData` 将 `type` 指定为 `session`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   * @param {any} value 要写入的值,如不存在则删除该数据
   */
  setData (type, dataName, value){
    // 如果不存在值则删除此数据
    if(typeof(value) === 'undefined'){
      this.removeData(type, dataName)
      this.dblog('setData', 'No value, and removed '+dataName)
      return
    }
    switch (type) {
      case 'GM':  // 脚本管理器同宫的全局存储
        if( !this.hasGMApi('GM_setValue', 'setData') ) {
          throw new Error("This API is not available.")
        }else{
          this.GM_setValue(dataName, value)
        }
        break;
      case 'local': // localStorage
        if( !window.localStorage ) {
          throw new Error("This API is not available.")
        }else{
          localStorage.setItem( dataName, JSON.stringify(value) )
        }
        break;
      case 'session': // sessionStorage
        if( !window.sessionStorage ) {
          throw new Error("This API is not available.")
        }else{
          sessionStorage.setItem( dataName, JSON.stringify(value) )
        }
        break;
      case 'cookie':  // cookies
        this.dblog('setData', 'The method of cookie operation is not yet available.')
        break;
      default:
        this.dblog('setData', 'Probably set the wrong type of get method.')
        throw new Error("Probably set the wrong type of get method.")
        break;
    }
  }
  setGMData (...args){
    try {
      this.setData.apply(this, ['GM'].concat(args))
    } catch (error) {
      this.dblog('setGMData', error.message)
    }
  }
  setLocalData (...args){
    try {
      this.setData.apply(this, ['local'].concat(args))
    } catch (error) {
      this.dblog('setLocalData', error.message)
    }
  }
  setSessionData (...args){
    try {
      this.setData.apply(this, ['session'].concat(args))
    } catch (error) {
      this.dblog('setSessionData', error.message)
    }
  }
  /**
   * @summary 移除存储中的数据
   * @description 基础方法,当接口不可用时会直接抛出错误,不建议直接使用。提供如下语法糖:
   * * `removeGMData` 将 `type` 指定为 `GM`,然后省略此参数
   * * `removeLocalData` 将 `type` 指定为 `local`,然后省略此参数
   * * `removeSessionData` 将 `type` 指定为 `session`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   */
  removeData (type, dataName){
    switch (type) {
      case 'GM':  // 脚本管理器同宫的全局存储
        if( !this.hasGMApi('GM_deleteValue', 'removeData') ) {
          throw new Error("This API is not available.")
        }else{
          this.GM_deleteValue(dataName)
        }
        break;
      case 'local': // localStorage
        if( !window.localStorage ) {
          throw new Error("This API is not available.")
        }else{
          localStorage.removeItem( dataName )
        }
        break;
      case 'session': // sessionStorage
        if( !window.sessionStorage ) {
          throw new Error("This API is not available.")
        }else{
          sessionStorage.removeItem( dataName )
        }
        break;
      case 'cookie':  // cookies
        this.dblog('removeData', 'The method of cookie operation is not yet available.')
        break;
      default:
        this.dblog('removeData', 'Probably set the wrong type of get method.')
        throw new Error("Probably set the wrong type of get method.")
        break;
    }
  }
  removeGMData (...args){
    try {
      this.removeData.apply(this, ['GM'].concat(args))
    } catch (error) {
      this.dblog('removeGMData', error.message)
    }
  }
  removeLocalData (...args){
    try {
      this.removeData.apply(this, ['local'].concat(args))
    } catch (error) {
      this.dblog('removeLocalData', error.message)
    }
  }
  removeSessionData (...args){
    try {
      this.removeData.apply(this, ['session'].concat(args))
    } catch (error) {
      this.dblog('removeSessionData', error.message)
    }
  }
  /**
   * @summary 移除存储中的数据
   * @description 基础方法,当接口不可用时会直接抛出错误,不建议直接使用。提供如下语法糖:
   * * `removeGMData` 将 `type` 指定为 `GM`,然后省略此参数
   * * `removeLocalData` 将 `type` 指定为 `local`,然后省略此参数
   * * `removeSessionData` 将 `type` 指定为 `session`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   */
  listData (type){
    switch (type) {
      case 'GM':  // 脚本管理器同宫的全局存储
        if( !this.hasGMApi('GM_listValues', 'removeData') ) {
          throw new Error("This API is not available.")
        }else{
          return this.GM_listValues()
        }
        break;
      case 'local': // localStorage
        if( !window.localStorage ) {
          throw new Error("This API is not available.")
        }else{
          return (new Array(localStorage.length)).fill(0).map((e,i)=>localStorage.key(i))
        }
        break;
      case 'session': // sessionStorage
        if( !window.sessionStorage ) {
          throw new Error("This API is not available.")
        }else{
          return (new Array(sessionStorage.length)).fill(0).map((e,i)=>sessionStorage.key(i))
        }
        break;
      case 'cookie':  // cookies
        this.dblog('setData', 'The method of cookie operation is not yet available.')
        break;
      default:
        this.dblog('listData', 'Probably set the wrong type of get method.')
        throw new Error("Probably set the wrong type of get method.")
        break;
    }
  }
  listGMData (){
    try {
      return this.listData.apply(this, ['GM'])
    } catch (error) {
      this.dblog('listGMData', error.message)
    }
  }
  listLocalData (){
    try {
      return this.listData.apply(this, ['local'])
    } catch (error) {
      this.dblog('listLocalData', error.message)
    }
  }
  listSessionData (){
    try {
      return this.listData.apply(this, ['session'])
    } catch (error) {
      this.dblog('listSessionData', error.message)
    }
  }
  /**
   * @summary 代理存储中的数据对象
   * @description 基础方法,不建议直接使用。仅用 `new Proxy()` 方法简单代理了数据的 `get` 和 `set`,可以满足简单的数据操作需求,复杂情况请自行书写代理。提供如下语法糖:
   * * `proxyGMData` 将 `type` 指定为 `GM`,然后省略此参数
   * * `proxyLocalData` 将 `type` 指定为 `local`,然后省略此参数
   * * `proxySessionData` 将 `type` 指定为 `session`,然后省略此参数
   * * `proxyDataAuto` 脚本数据存储 API 可用的话 `type` 指定为 `GM`,否则为 `local` 即自动选择 `type`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   * @param {any} defaultValue={} 默认值,可省略
   */
  proxyData(type, dataName, defaultValue={}){
    if(typeof(defaultValue) !== 'undefined' && !(defaultValue instanceof Object) ){
      this.dblog('The defaultValue must be an object, and the proxy method can only be used to observe a change in an object\'s property value.')
      return
    }
    return new Proxy(
      defaultValue,
      {
        get: (target, property, receiver)=>{
          return (Object.assign(
            target,
            this.getData(type, dataName, {})
          ))[property]
        },
        set: (target, property, value, receiver)=>{
          (Object.assign(
            target,
            this.getData(type, dataName, {})
          ))[property] = value
          try {
            this.setData(type, dataName, target)
            return true
          } catch (error) {
            this.dblog('proxyData', error)
            return false
          }
        }
      }
    )
  }
  proxyGMData (...args){
    try {
      return this.proxyData.apply(this, ['GM'].concat(args))
    } catch (error) {
      this.dblog('proxyGMData', error.message)
    }
  }
  proxyLocalData (...args){
    try {
      return this.proxyData.apply(this, ['local'].concat(args))
    } catch (error) {
      this.dblog('proxyLocalData', error.message)
    }
  }
  proxySessionData (...args){
    try {
      return this.proxyData.apply(this, ['session'].concat(args))
    } catch (error) {
      this.dblog('proxySessionData', error.message)
    }
  }
  proxyDataAuto (...args){
    let type = 'local'
    if (
      this.hasGMApi('GM_getValue', 'proxyDataAuto') &&
      this.hasGMApi('GM_setValue', 'proxyDataAuto')
    ) {
      if(this.getGMData('GM_Can_be_Used', false)){
        type = 'GM'
      }else{
        this.setGMData('GM_Can_be_Used', true)
        if(this.getGMData('GM_Can_be_Used', false)){
          type = 'GM'
        }
      }
    }
    try {
      return this.proxyData.apply(this, [type].concat(args))
    } catch (error) {
      this.dblog('proxyDataAuto', error.message)
    }
  }
  /**
   * @summary 代理存储中的数据
   * @description 基础方法,不建议直接使用。针对存储的单个数据进行简单代理。提供如下语法糖:
   * * `proxyGMKey` 将 `type` 指定为 `GM`,然后省略此参数
   * * `proxyLocalKey` 将 `type` 指定为 `local`,然后省略此参数
   * * `proxySessionKey` 将 `type` 指定为 `session`,然后省略此参数
   * * `proxyKeyAuto` 脚本数据存储 API 可用的话 `type` 指定为 `GM`,否则为 `local` 即自动选择 `type`,然后省略此参数
   * @param {string} type 存储类型,可用值如下:
   * * `GM` 脚本管理器提供的数据存储
   * * `local` localStorage 数据
   * * `session` sessionStorage 数据
   * * `cookie` cookie 数据(暂未提供
   * @param {string} dataName 数据名称
   * @param {any} defaultValue=false 默认值,可省略
   * @returns {function} 返回一个方法,无参数运行则获取对应存储的值,有参数运行则将参数值写入存储
   */
  proxyKey(type, dataName, defaultValue=false){
    return value=>{
      if(typeof(value) !== 'undefined'){
        this.setData(type, dataName, value)
      }else{
        return this.getData(type, dataName, defaultValue)
      }
    }
  }
  proxyGMKey (...args){
    try {
      return this.proxyKey.apply(this, ['GM'].concat(args))
    } catch (error) {
      this.dblog('proxyGMKey', error.message)
    }
  }
  proxyLocalKey (...args){
    try {
      return this.proxyKey.apply(this, ['local'].concat(args))
    } catch (error) {
      this.dblog('proxyLocalKey', error.message)
    }
  }
  proxySessionKey (...args){
    try {
      return this.proxyKey.apply(this, ['session'].concat(args))
    } catch (error) {
      this.dblog('proxySessionKey', error.message)
    }
  }
  proxyKeyAuto (...args){
    let type = 'local'
    if (
      this.hasGMApi('GM_getValue', 'proxyKeyAuto') &&
      this.hasGMApi('GM_setValue', 'proxyKeyAuto')
    ) {
      if(this.getGMKey('GM_Can_be_Used', false)){
        type = 'GM'
      }else{
        this.setGMKey('GM_Can_be_Used', true)
        if(this.getGMKey('GM_Can_be_Used', false)){
          type = 'GM'
        }
      }
    }
    try {
      return this.proxyKey.apply(this, [type].concat(args))
    } catch (error) {
      this.dblog('proxyKeyAuto', error.message)
    }
  }
  /* ====== 菜单相关 ====== */
  /**
   * @summary 注册一个链接菜单
   * @description 需要如下 API:
   * * `GM_registerMenuCommand` 【必须】否则无法注册脚本菜单
   * * `GM_openInTab` 【可选】可以更稳定的在新窗口打开链接
   * @param {string} name 菜单的名称
   * @param {url} url 链接地址,含协议部分(`http://`,`https://`),否则可能按相对地址处理
   * @example DMSTookit.menuLink('更多脚本', 'https://script.izyx.xyz/')
   * @memberof Toolkit
   */
  menuLink (name, url){
    if(!this.hasGMApi('GM_registerMenuCommand', 'menuLink')) return
    GM_registerMenuCommand(name, ()=>{
      if(this.hasGMApi('GM_openInTab', 'menuLink')){
        GM_openInTab(url)
        return
      }
      window.open(url, '_blank')
    })
  }
  /**
   * @summary 注册一个切换菜单
   * @description 因为不同管理器下菜单的反注册方式不太一样,所以最稳妥的方法是切换后刷新页面。如果不刷新页面,会用两种方法尝试反注册菜单。需要如下 API:
   * * `GM_registerMenuCommand` 【必须】否则无法注册脚本菜单
   * * `GM_unregisterMenuCommand` 【可选】无刷新切换时用来反注册菜单
   * 提供如下语法糖:
   * * `menuToggle` 菜单标记位于某个数据对象的属性上,用此方法可略显简便,详细参数在后面
   * * `menuDebug` 注册一个 debug 状态的切换菜单,此方法无参数
   * @param {function} getter 获取菜单状态标记
   * @param {function} setter 设置菜单状态标记
   * @param {string} onName 启用状态菜单文字
   * @param {string} offName 禁用状态菜单文字
   * @param {boolean} reload=true 是否需要刷新页面,默认为是。当为否时会尝试两种方法反注册菜单,但并不推荐。
   * @param {function} callback 回调函数,当 `reload=false` 时可用,会传入当前菜单状态(`true`,`false`)
   * @memberof Toolkit
  */
  menuToggleBasic (getter, setter, onName, offName, reload=true, callback){
    if(!this.hasGMApi('GM_registerMenuCommand', 'toggleMenu')) return
    if(!reload && !this.hasGMApi('GM_unregisterMenuCommand', 'toggleMenu')) return
    const registerMenu = ()=>{
      const nowState = getter()
      const menuName = nowState ? onName : offName
      let menuMarkID
      menuMarkID = GM_registerMenuCommand(menuName, ()=>{
        setter(!nowState)
        if(reload){
          window.location.reload(1)
          return
        }
        if(menuMarkID) {  // Tampermonkey
          GM_unregisterMenuCommand(menuMarkID)
        }else{  // Violentmonkey
          GM_unregisterMenuCommand(menuName)
        }
        registerMenu()
        if(callback) callback(!nowState)
      })
    }
    registerMenu()
  }
  /**
   * @summary 注册一个切换菜单,菜单标记位于储存数据对象的某个属性上
   * @param {object} optionsObject 选项数据对象,推荐使用上面 `proxyData` 方法代理的数据,这样改变后可以直接存储
   * @param {string} optionsProperty 选项对象中对应的属性名称
   * @param {string} onName 启用状态菜单文字
   * @param {string} offName 禁用状态菜单文字
   * @param {boolean} reload=true 是否需要刷新页面,默认为是。当为否时会尝试两种方法反注册菜单,但并不推荐。
   * @param {function} callback 回调函数,当 `reload=false` 时可用,会传入当前菜单状态(`true`,`false`)
   * @memberof Toolkit
  */
  menuToggle (optionsObject, optionsProperty, ...args){
    this.menuToggleBasic.apply(this, [
      () => { return optionsObject[optionsProperty] },
      state => { optionsObject[optionsProperty] = state }
    ].concat(args))
  }
  menuDebug(){
    const isdebug = this.proxyGMKey('is_debug', false)
    this.menuToggleBasic (
      isdebug,
      isdebug,
      'Debug opening……',
      'Debug closed.'
    )
  }
  /* ====== 页面变化监控 ====== */
  /**
   * @summary 创建页面元素变化监控
   * @description 用来实时跟踪页面元素变化,及时进行相应修改,但可能和页面程序产生冲突,在执行回调函数期间可能错过某些变化。仅作为对页面的元素的粗略追踪方法。
   * @param {object} targetEl 要监控的元素对象
   * @param {function} callback 元素发生变化时执行的函数,应该为一个异步函数,会在执行此函数之间停止监控,等待此函数执行结束后再次启动监控
   * @param {object} [obOptions={}] 监控设置项,参见: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserverInit
   * @return {object} 一个对象,提供两个方法,`start` 开始此监控,`stop` 停止此监控
   * @memberof Toolkit
   */
  pageObserverInit (targetEl, callback, obOptions={}) {
    const options = Object.assign({
      childList: true,
      subtree: true,
      attributes: true,
      characterData: true,
      attributeOldValue: false,
      characterDataOldValue: false,
      attributeFilter: [],
    }, obOptions)
    const observer = new MutationObserver(async (records, observer) => {
      observer.disconnect();
      await callback(records);
      // 页面处理完成之后重新监控页面变化
      observer.observe(targetEl, options);
    })
    return {
      start: ()=>{ observer.observe(targetEl, options) },
      stop: ()=>{ observer.disconnect() }
    }
  }
  /**
   * @summary 对 `MutationRecord` 对象的预处理
   * @description 根据变化类型将受到影响的元素放入数组并标记变化类型,方便后期直接通过对数组的遍历处理。
   * @param {*} records `MutationRecord` 对象。参见: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationRecord
   * @memberof Toolkit
   */
  recordsPreProcessing (records){
    const result = []
    records.forEach(el=>{
      // 属性变化
      if(/^attributes$/i.test(el.type)){
        result.push({
          'type': 'attributes',
          'el': el.target
        })
        return
      }
      // 内容变化
      if(/^characterData$/i.test(el.type)){
        result.push({
          'type': 'characterData',
          'el': el.target
        })
        return
      }
      // 后代元素变化
      if(/^childList$/i.test(el.type)){
        el.addedNodes.forEach(node => result.push({
          'type': 'childListAdd',
          'el': node
        }));
        el.removedNodes.forEach(node => result.push({
          'type': 'childListRemove',
          'el': node.parentElement
        }));
        return
      }
    })
    return result
  }
  /* ====== 基础 API 替代 ====== */
  /**
   * @summary 为页面添加样式
   * @description 当没有 `GM_addStyle` 接口或执行错误时,使用替代方法
   * @param {string} cssString 要注入页面的 CSS 字符串
   * @memberof Toolkit
   */
  addStyle (cssString){
    if( this.hasGMApi('GM_addStyle', 'addStyle') ) {
      try {
        GM_addStyle(cssString)
        return
      } catch (error) {
        this.dblog('addStyle', error)
      }
    }
    // 重写添加 style 功能,以兼容无接口的运行环境
    const style = document.createElement('style')
    // 添加额外 ID 便于识别
    style.id = 'expand-the-article-for-me'
    style.innerHTML = cssString
    document.head.appendChild(style)
  }
}