B站封面获取

获取B站各播放页及直播间封面,支持手动及实时预览等多种模式,支持点击下载、封面预览、快速复制,可高度自定义

スクリプトをインストール?
作者が勧める他のスクリプト

B站稍后再看功能增强も気に入るかもしれません。

スクリプトをインストール
このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name            B站封面获取
// @version         5.10.7.20240827
// @namespace       laster2800
// @author          Laster2800
// @description     获取B站各播放页及直播间封面,支持手动及实时预览等多种模式,支持点击下载、封面预览、快速复制,可高度自定义
// @icon            https://www.bilibili.com/favicon.ico
// @homepageURL     https://greasyfork.org/zh-CN/scripts/395575
// @supportURL      https://greasyfork.org/zh-CN/scripts/395575/feedback
// @license         LGPL-3.0
// @include         *://www.bilibili.com/video/*
// @include         *://www.bilibili.com/list/*
// @include         *://www.bilibili.com/bangumi/play/*
// @include         *://www.bilibili.com/medialist/play/watchlater
// @include         *://www.bilibili.com/medialist/play/watchlater/*
// @include         *://www.bilibili.com/medialist/play/ml*
// @include         /https?:\/\/live\.bilibili\.com\/(blanc\/)?\d+([/?]|$)/
// @require         https://update.greasyfork.org/scripts/409641/1435266/UserscriptAPI.js
// @require         https://update.greasyfork.org/scripts/431998/1161016/UserscriptAPIDom.js
// @require         https://update.greasyfork.org/scripts/432000/1095149/UserscriptAPIMessage.js
// @require         https://update.greasyfork.org/scripts/432002/1161015/UserscriptAPIWait.js
// @require         https://update.greasyfork.org/scripts/432003/1381253/UserscriptAPIWeb.js
// @grant           GM_download
// @grant           GM_notification
// @grant           GM_xmlhttpRequest
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_deleteValue
// @grant           GM_listValues
// @grant           GM_registerMenuCommand
// @grant           GM_unregisterMenuCommand
// @connect         api.bilibili.com
// @run-at          document-start
// @compatible      edge 版本不小于 93
// @compatible      chrome 版本不小于 93
// @compatible      firefox 版本不小于 92
// ==/UserScript==

(function() {
  'use strict'

  const gmId = 'gm395575'
  const defaultRealtimeStyle = `
    #${gmId}-realtime-cover {
      display: block;
      margin-bottom: 18px;
      border-radius: 6px;
      overflow: hidden;
    }
    #${gmId}-realtime-cover img {
      display: block;
      width: 100%;
    }
  `.trim().replaceAll(/\s+/g, ' ')

  const gm = {
    id: gmId,
    configVersion: GM_getValue('configVersion'),
    configUpdate: 20210815,
    config: {},
    configMap: {
      mode: { default: -1, name: '视频/番剧:工作模式' },
      customModeSelector: { default: '#danmukuBox' },
      customModePosition: { default: 'beforebegin' },
      customModeQuality: { default: '480w_90p' }, // 320w 会有肉眼可见的质量损失
      customModeStyle: { default: defaultRealtimeStyle },
      download: { default: true, name: '全局:点击下载', checkItem: true },
      preview: { default: true, name: '视频/番剧:封面预览', checkItem: true },
      previewLive: { default: true, name: '直播间:封面预览', checkItem: true },
      bangumiSeries: { default: false, name: '番剧:获取系列封面而非分集封面', checkItem: true },
      switchQuickCopy: { default: false, name: '全局:交换「右键」与「Ctrl+右键」功能', checkItem: true, needNotReload: true },
      disableContextMenu: { default: true, name: '全局:在预览图上禁用右键菜单', checkItem: true },
    },
    runtime: {
      /** @type {'legacy' | 'realtime'} */
      layer: null,
      modeName: null,
      preview: null,
      realtimeSelector: null,
      /** @type {'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'} */
      realtimePosition: null,
      realtimeQuality: null,
      realtimeStyle: null,
    },
    url: {
      api_videoInfo: (id, type) => `https://api.bilibili.com/x/web-interface/view?${type}=${id}`,
      gm_changelog: 'https://gitee.com/liangjiancang/userscript/blob/master/script/BilibiliCover/changelog.md',
    },
    regex: {
      page_videoNormalMode: /\.com\/video([#/?]|$)/,
      page_videoWatchlaterMode: /\.com\/medialist\/play\/(watchlater|ml\d+)([#/?]|$)/,
      page_listMode: /\.com\/list\/.+/,
      page_bangumi: /\/bangumi\/play([#/?]|$)/,
      page_live: /live\.bilibili\.com\/(blanc\/)?\d+([#/?]|$)/, // 只含具体的直播间页面
    },
    const: {
      hintText: `
        <div style="display:grid;grid-template-columns:auto auto;column-gap:1.5em;font-size:0.8em">
          <div>左键:下载 / 在新页面打开</div>
          <div>右键:复制链接 / 内容</div>
          <div>中键:在新页面打开</div>
          <div>Ctrl+右键:复制内容 / 链接</div>
        </div>
      `,
      errorMsg: '获取失败,请尝试在页面加载完成后获取',
      customMode: 32767,
      fadeTime: 200,
      noticeTimeout: 5600,
    },
  }

  /* global UserscriptAPI */
  const api = new UserscriptAPI({
    id: gm.id,
    label: GM_info.script.name,
    wait: {
      condition: {
        interval: 250,
        timeout: 15000,
        stopOnTimeout: false,
      },
    },
  })

  /** @type {Script} */
  let script = null
  /** @type {Webpage} */
  let webpage = null

  /**
   * 脚本运行的抽象,为脚本本身服务的核心功能
   */
  class Script {
    /** 通用方法 */
    method = {
      /**
       * 重置脚本
       */
      reset() {
        const gmKeys = GM_listValues()
        for (const gmKey of gmKeys) {
          GM_deleteValue(gmKey)
        }
      },
    }

    /**
     * 初始化脚本
     */
    init() {
      try {
        this.updateVersion()
        for (const [name, item] of Object.entries(gm.configMap)) {
          const v = GM_getValue(name)
          const dv = item.default
          gm.config[name] = typeof v === typeof dv ? v : dv
        }
        this.initRuntime()

        if (gm.config.mode === gm.configMap.mode.default) {
          this.configureMode(true)
        }
      } catch (e) {
        api.logger.error(e)
        api.message.confirm('初始化错误!是否彻底清空内部数据以重置脚本?').then(result => {
          if (result) {
            this.method.reset()
            location.reload()
          }
        })
      }
    }

    /**
     * 初始化运行时变量
     */
    initRuntime() {
      const rt = gm.runtime
      const { mode } = gm.config
      rt.layer = mode > 1 ? 'realtime' : 'legacy'
      rt.preview = api.base.urlMatch(gm.regex.page_live) ? gm.config.previewLive : gm.config.preview
      rt.modeName = { '-1': '初始化', '1': '传统', '2': '实时预览', [gm.const.customMode]: '自定义' }[mode] ?? '未知'
      if (rt.layer === 'realtime') {
        for (const s of ['Selector', 'Position', 'Style']) {
          rt['realtime' + s] = mode === 2 ? gm.configMap['customMode' + s].default : gm.config['customMode' + s]
        }
        rt.realtimeQuality = mode === 2 ? gm.configMap.customModeQuality.default : gm.config.customModeQuality
      }
    }

    /**
     * 初始化脚本菜单
     */
    initScriptMenu() {
      const _self = this
      const cfgName = id => `[ ${config[id] ? '✓' : '✗'} ] ${configMap[id].name}`
      const { config, configMap, runtime } = gm
      const menuMap = {}

      menuMap.mode = GM_registerMenuCommand(`${configMap.mode.name} [ ${runtime.modeName} ]`, () => this.configureMode())
      for (const [id, item] of Object.entries(configMap)) {
        if (item.checkItem) {
          menuMap[id] = createMenuItem(id)
        }
      }
      menuMap.reset = GM_registerMenuCommand('初始化脚本', () => this.resetScript())

      function createMenuItem(id) {
        return GM_registerMenuCommand(cfgName(id), () => {
          config[id] = !config[id]
          GM_setValue(id, config[id])
          GM_notification({
            text: `已${config[id] ? '开启' : '关闭'}「${configMap[id].name}」功能${configMap[id].needNotReload ? '' : ',刷新页面以生效(点击通知以刷新)'}。`,
            timeout: gm.const.noticeTimeout,
            onclick: configMap[id].needNotReload ? null : () => location.reload(),
          })
          clearMenu()
          _self.initScriptMenu()
        })
      }

      function clearMenu() {
        for (const menuId of Object.values(menuMap)) {
          GM_unregisterMenuCommand(menuId)
        }
      }
    }

    /**
     * 版本更新处理
     */
    updateVersion() {
      if (gm.configVersion >= 20210811) { // 5.0.0.20210811
        if (gm.configVersion < gm.configUpdate) {
          // 必须按从旧到新的顺序写
          // 内部不能使用 gm.configUpdate,必须手写更新后的配置版本号!

          // 5.0.5.20210812
          if (gm.configVersion < 20210812) {
            GM_deleteValue('mode')
            GM_deleteValue('customModeStyle')
          }

          // 5.2.0.20210813
          if (gm.configVersion < 20210813) {
            GM_deleteValue('preview')
          }

          // 功能性更新后更新此处配置版本
          if (gm.configVersion < 20210815) {
            GM_notification({
              text: '功能性更新完毕,你可能需要重新设置脚本。点击查看更新日志。',
              onclick: () => window.open(gm.url.gm_changelog),
            })
          }
        }
        if (gm.configVersion !== gm.configUpdate) {
          gm.configVersion = gm.configUpdate
          GM_setValue('configVersion', gm.configVersion)
        }
      } else {
        this.method.reset()
        gm.configVersion = gm.configUpdate
        GM_setValue('configVersion', gm.configVersion)
      }
    }

    /**
     * 初始化脚本
     */
    async resetScript() {
      const result = await api.message.confirm('是否要初始化脚本?')
      if (result) {
        const gmKeys = GM_listValues()
        for (const gmKey of gmKeys) {
          GM_deleteValue(gmKey)
        }
        gm.configVersion = gm.configUpdate
        GM_setValue('configVersion', gm.configVersion)
        location.reload()
      }
    }

    /**
     * 设置工作模式
     * @param {boolean} [reload] 强制刷新
     */
    async configureMode(reload) {
      let result = null
      let msg = null
      let val = null

      val = gm.config.mode
      val = val === -1 ? 1 : val
      msg = `
        <p style="margin-bottom:0.5em">输入对应序号选择脚本工作模式。输入值应该是一个数字。</p>
        <p>[ 1 ] - 传统模式。在视频播放器下方添加一个「获取封面」按钮,与该按钮交互以获得封面。</p>
        <p>[ 2 ] - 实时预览模式。直接在视频播放器右方显示封面,与其交互可进行更多操作。</p>
        <p>[ ${gm.const.customMode} ] - 自定义模式。底层机制与预览模式相同,但封面位置及显示效果由用户自定义,运行效果仅局限于想象力。</p>
      `
      result = await api.message.prompt(msg, val, { html: true })
      if (result == null) return
      result = Number.parseInt(result)
      if ([1, 2, gm.const.customMode].includes(result)) {
        gm.config.mode = result
        GM_setValue('mode', result)
      } else {
        gm.config.mode = -1
        await api.message.alert('设置失败,请填入正确的参数。')
        return this.configureMode()
      }

      if (gm.config.mode === gm.const.customMode) {
        val = gm.config.customModeSelector
        msg = `
          <p style="margin-bottom:0.5em">请认真阅读以下说明:</p>
          <p>1. 应填入 CSS 选择器,脚本会以此选择定位元素,将封面元素「<code>#${gm.id}-realtime-cover</code>」插入到其附近(相对位置稍后设置)。</p>
          <p>2. 确保该选择器在各种播放页面中均有对应元素,否则脚本在对应页面无法工作。PS:逗号「<code>,</code>」以 OR 规则拼接多个选择器。</p>
          <p>3. 不要选择广告为定位元素,否则封面元素可能会插入失败或被误杀。</p>
          <p>4. 不要选择时有时无的元素,或第三方插入的元素作为定位元素,否则封面元素可能会插入失败。</p>
          <p>5. 在 A 时间点插入的图片元素,有可能被 B 时间点插入的新元素 C 挤到目标以外的位置。只要将定位元素选择为 C 再更改相对位置即可解决问题。</p>
          <p>6. 置空时使用默认设置。</p>
        `
        result = await api.message.prompt(msg, val, { html: true })
        if (result != null) {
          result = result.trim()
          if (result === '') {
            result = gm.configMap.customModeSelector.default
          }
          gm.config.customModeSelector = result
          GM_setValue('customModeSelector', result)
        }

        val = gm.config.customModePosition
        msg = `
          <p style="margin-bottom:0.5em">设置封面元素相对于定位元素的位置。</p>
          <p>[ <code>beforebegin</code> ] - 作为兄弟元素插入到定位元素前方</p>
          <p>[ <code>afterbegin</code> ] - 作为第一个子元素插入到定位元素内</p>
          <p>[ <code>beforeend</code> ] - 作为最后一个子元素插入到定位元素内</p>
          <p>[ <code>afterend</code> ] - 作为兄弟元素插入到定位元素后方</p>
        `
        result = null
        const loop = () => !['beforebegin', 'afterbegin', 'beforeend', 'afterend'].includes(result)
        while (loop()) {
          result = await api.message.prompt(msg, val, { html: true })
          if (result == null) break
          result = result.trim()
          if (loop()) {
            await api.message.alert('设置失败,请填入正确的参数。')
          }
        }
        if (result != null) {
          gm.config.customModePosition = result
          GM_setValue('customModePosition', result)
        }

        val = gm.config.customModeQuality
        msg = `
          <p>设置实时预览图片的质量,该项会明显影响页面加载的视觉体验。</p>
          <p>设置为 [ <code>best</code> ] 加载原图(不推荐),置空时使用默认设置。</p>
          <p style="margin-bottom:0.5em">PS:B站推荐的视频封面长宽比为 16:10(非强制性标准)。</p>
          <p>格式:[ <code>${'${width}w_${height}h_${clip}c_${quality}q'}</code> ]</p>
          <p>可省略部分参数,如 [ <code>320w_1q</code> ] 表示「宽度 320 像素,高度自动,拉伸,压缩质量 1」</p>
          <div style="text-indent:3em">
            <p><code>width</code>:&emsp;图片宽度</p>
            <p><code>height</code>:&emsp;图片高度</p>
            <p><code>clip</code>:&emsp;1 裁剪,0 拉伸;默认 0</p>
            <p><code>quality</code>:&emsp;有损压缩参数,100 为无损;默认 100</p>
          </div>
        `
        result = await api.message.prompt(msg, val, { html: true })
        if (result != null) {
          result = result.trim()
          if (result === '') {
            result = gm.configMap.customModeQuality.default
          }
          gm.config.customModeQuality = result
          GM_setValue('customModeQuality', result)
        }

        val = gm.config.customModeStyle
        msg = `
          <p style="margin-bottom:0.5em">设置封面元素的样式。设置为 [<code>disable</code>] 禁用样式,置空时使用默认设置。</p>
          <p>这里提供几种目标效果以便拓宽思路:</p>
          <p>* 鼠标悬浮至封面元素上方时放大封面实现预览效果(图片质量应与放大后的尺寸匹配)。</p>
          <p>* 将内部 <code>&lt;img&gt;</code> 隐藏,使用 Base64 图片或 SVG 将封面元素改成任何样子。</p>
          <p>* 将封面元素做成透明层覆盖在视频投稿时间上,实现点击投稿时间下载封面的效果。</p>
          <p>* 将页面背景替换为视频封面,再加个滤镜也许还会有不错的设计感?</p>
          <p>* ......</p>
        `
        result = await api.message.prompt(msg, val, { html: true })
        if (result != null) {
          result = result.trim()
          result = (result === '') ? gm.configMap.customModeStyle.default : result.replaceAll(/\s+/g, ' ')
          gm.config.customModeStyle = result
          GM_setValue('customModeStyle', result)
        }
      }

      if (reload || await api.message.confirm('配置工作模式完成,需刷新页面方可生效。是否立即刷新页面?')) {
        location.reload()
      }
    }
  }

  /**
   * 页面处理的抽象,脚本围绕网站的特化部分
   */
  class Webpage {
    /** 通用方法 */
    method = {
      /**
       * 下载封面
       * @param {string} url 封面 URL
       * @param {string} [name='Cover'] 保存文件名
       */
      download(url, name) {
        name ||= 'Cover'
        async function onerror(error) {
          if (error?.error === 'not_whitelisted') {
            await api.message.alert('该封面的文件格式不在下载模式白名单中,从而触发安全限制导致无法直接下载。可修改脚本管理器的「下载模式」或「文件扩展名白名单」设置以放开限制。')
            window.open(url)
          } else {
            GM_notification({
              text: '下载错误',
              timeout: gm.const.noticeTimeout,
            })
          }
        }
        function ontimeout() {
          GM_notification({
            text: '下载超时',
            timeout: gm.const.noticeTimeout,
          })
          window.open(url)
        }
        api.web.download({ url, name, onerror, ontimeout })
      },

      /**
       * 从 URL 获取视频 ID
       * @param {string} [url=location.href] 提取视频 ID 的源字符串
       * @returns {{id: string, type: 'aid' | 'bvid'}} `{id, type}`
       */
      getVid(url = location.href) {
        let m = null
        if ((m = /(\/|bvid=)bv([\da-z]+)([#&/?]|$)/i.exec(url))) {
          return { id: 'BV' + m[2], type: 'bvid' }
        } else if ((m = /(\/(av)?|aid=)(\d+)([#&/?]|$)/i.exec(url))) { // 兼容 BV 号被第三方修改为 AV 号的情况
          return { id: m[3], type: 'aid' }
        }
        return null
      },

      /**
       * 从 URL 获取番剧 ID
       * @param {string} [url=location.href] 提取视频 ID 的源字符串
       * @returns {{id: string, type: 'ssid' | 'epid'}} `{id, type}`
       */
      getBgmid(url = location.href) {
        let m = null
        if ((m = /\/(ss\d+)([#/?]|$)/.exec(url))) {
          return { id: m[1], type: 'ssid' }
        } else if ((m = /\/(ep\d+)([#/?]|$)/.exec(url))) {
          return { id: m[1], type: 'epid' }
        }
        return null
      },

      /**
       * 添加下载图片事件
       * @param {HTMLElement} target 触发元素
       */
      addDownloadEvent(target) {
        if (!target._downloadEvent) {
          // 此处必须用 mousedown,否则无法与动态获取封面的代码达成正确的联动
          target.addEventListener('mousedown', e => {
            if (target.loaded && gm.config.download && e.button === 0) {
              this.download(target.href, document.title)
            }
          })
          // 开启下载时,若没有以下处理器,则鼠标左键长按图片按钮,过一段时间后再松开,松开时依然会触发默认点击事件(在新页面打开封面)
          target.addEventListener('click', e => {
            if (target.loaded && gm.config.download) {
              e.preventDefault()
              e.stopPropagation() // 兼容第三方的「链接转点击事件」处理
            }
          })
          target._downloadEvent = true
        }
      },

      /**
       * 添加复制事件
       * @param {HTMLElement} target 触发元素
       */
      addCopyEvent(target) {
        if (!target._copyLinkEvent) {
          target.addEventListener('mousedown', async e => {
            if (target.loaded && e.button === 2) {
              let ctrl = e.ctrlKey
              if (gm.config.switchQuickCopy) {
                ctrl = !ctrl
              }
              if (ctrl) {
                // 借助 image 中转避免跨域;网络请求其实更简单,但还是防一手某些封面图不在 i0.hdslb.com 的情况
                // 理论上来说这里可以复用 realtime-image 或者 preview,但是很麻烦,再考虑到图片缓存也没必要
                const image = new Image()
                image.crossOrigin = 'Anonymous'
                image.src = target.href
                image.addEventListener('load', () => {
                  const canvas = document.createElement('canvas')
                  const ctx = canvas.getContext('2d')
                  canvas.width = image.width
                  canvas.height = image.height
                  ctx.drawImage(image, 0, 0)
                  canvas.toBlob(async blob => {
                    try {
                      await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
                      api.message.info('已复制封面内容')
                    } catch (e) {
                      api.logger.warn(e)
                      api.message.info('当前浏览器不支持复制图片')
                    }
                  })
                })
              } else {
                try {
                  await navigator.clipboard.writeText(target.href)
                  api.message.info('已复制封面链接')
                } catch (e) {
                  // 只要脚本管理器有向浏览器要剪贴板权限就没问题
                  api.logger.warn(e)
                  api.message.info('当前浏览器不支持剪贴板')
                }
              }
            }
          })
          target._copyLinkEvent = true
        }
      },

      /**
       * 设置提示信息
       * @param {HTMLElement} target 目标元素
       * @param {string} hintText 提示信息
       */
      setHintText(target, hintText) {
        if (target.hoverInfo) {
          target.hoverInfo.msg = hintText
        } else {
          api.message.hoverInfo(target, hintText, null, { position: { top: '94%' } })
        }
      },

      /**
       * 设置封面
       * @param {HTMLElement} target 封面元素
       * @param {HTMLElement} preview 预览元素,无预览元素时传空值即可
       * @param {string} url 封面 URL
       */
      setCover(target, preview, url) {
        if (url) {
          if (/@\d+\w[._]/.test(url)) { // 若 url 指向缩略图,替换为原图
            url = url.replace(/@\d+\w[._].*/, '')
          }
          target.href = url
          target.target = '_blank'
          target.loaded = true
          this.setHintText(target, gm.const.hintText)
          this.addDownloadEvent(target)
          this.addCopyEvent(target)
          if (target.img) {
            if (gm.runtime.realtimeQuality !== 'best') {
              target.img.src = `${url}@${gm.runtime.realtimeQuality}.webp`
              target.img.lossless = url
            } else {
              target.img.src = url
            }
          }
          if (preview) {
            preview._src = url
          }
        } else {
          target.removeAttribute('href')
          target.loaded = false
          this.setHintText(target, gm.const.errorMsg)
          if (target.img) {
            target.img.removeAttribute('src')
            target.img.lossless = null
            target.error.style.display = 'block'
          }
          if (preview) {
            preview.removeAttribute('src')
          }
        }
      },

      /**
       * 创建预览元素
       * @param {HTMLElement} target 触发元素
       * @returns {HTMLImageElement}
       */
      createPreview(target) {
        const preview = document.body.appendChild(document.createElement('img'))
        preview.className = `${gm.id}-preview`
        preview.fadeOutNoInteractive = true
        const fade = inOut => api.dom.fade(inOut, preview)

        const onMouseenter = api.base.debounce(async () => {
          if (gm.runtime.preview) {
            if (preview._src) {
              try {
                await new Promise((resolve, reject) => {
                  preview.addEventListener('load', resolve, { once: true })
                  preview.addEventListener('error', reject, { once: true })
                  preview.src = preview._src
                  preview._src = null
                })
              } catch (e) {
                this.setCover(target, preview, null)
                api.logger.error(e)
                return
              }
            }
            target._mouseOver && preview.src && fade(true)
          }
        }, 200)
        const onMouseleave = api.base.debounce(() => {
          if (gm.runtime.preview) {
            fade(false)
          }
        }, 200)
        target.addEventListener('mouseenter', () => {
          target._mouseOver = true
          onMouseenter()
        })
        target.addEventListener('mouseleave', () => {
          target._mouseOver = false
          onMouseleave()
        })

        // 当图像太小时,放大以避免预览效果不佳
        const enlarge = () => {
          const widthCap = window.innerWidth * 0.65
          const heightCap = window.innerHeight * 0.8
          if (preview.naturalWidth / preview.naturalHeight > widthCap / heightCap) {
            if (preview.naturalWidth < widthCap) {
              preview.style.width = `${preview.naturalWidth * 1.5}px` // 限制图像放大倍率为 150%
            }
            preview.style.height = ''
          } else {
            if (preview.naturalHeight < heightCap) {
              preview.style.height = `${preview.naturalHeight * 1.5}px`
            }
            preview.style.width = ''
          }
        }
        preview.addEventListener('load', enlarge)
        window.addEventListener('resize', api.base.throttle(enlarge))

        return preview
      },

      /**
       * 创建实时封面元素
       * @returns {Promise<HTMLElement>} 实时封面元素
       */
      async createRealtimeCover() {
        const ref = await api.wait.$(gm.runtime.realtimeSelector)
        const cover = ref.insertAdjacentElement(gm.runtime.realtimePosition, document.createElement('a'))
        cover.id = `${gm.id}-realtime-cover`
        // 实时封面元素生成会导致页面中某些元素发生位置变动,若用户在刚打开页面时便去点击这些元素,可能会
        // 误点到刚生成的实时封面元素上,产生预期外的影响。为了将这种影响降至最少,实时封面元素在刚生成时
        // 应该是不可交互的,待位置变动结束一段时间后再恢复可交互状态。
        cover.style.pointerEvents = 'none'
        // 2022 版将绝大多数常见元素默认设为 pointer-event: none,再通过样式将部分元素设置回可交互的
        // 这里假定实时预览元素被设为不可交互的(就 2022.07 而言确实如此),最后要设为 auto 而非单纯清掉
        // 不要写进样式表,避免被不清楚原理的用户用样式覆盖掉
        const peCover = () => {
          if (cover.style.pointerEvents === 'none') {
            setTimeout(() => {
              cover.style.pointerEvents = 'auto'
            }, 1357)
          }
        }
        cover.img = cover.appendChild(document.createElement('img'))
        // 首次加载待完成再显示,避免观察到加载过程;后续加载浏览器会做优化,无需再手动处理
        // 不要写进样式表,避免被不清楚原理的用户用样式覆盖掉
        cover.img.style.display = 'none'
        cover.error = cover.appendChild(document.createElement('div'))
        cover.error.textContent = '封面获取失败'
        cover.img.addEventListener('load', () => {
          cover.img.style.display = ''
          cover.error.style.display = ''
          peCover()
        })
        cover.img.addEventListener('error', /** @param {Event} e */ e => {
          const { img } = cover
          if (img.lossless && img.src !== img.lossless) {
            if (gm.config.mode === gm.const.customMode) {
              api.message.info(`缩略图获取失败,使用原图进行替换!请检查「${gm.runtime.realtimeQuality}」是否为有效的图片质量参数。可能是正常现象,因为年代久远的视频封面有可能不支持缩略图。`, 4000)
            } else {
              api.message.info('缩略图获取失败,使用原图进行替换!可能是正常现象,因为年代久远的视频封面有可能不支持缩略图。', 3000)
            }
            api.logger.warn('缩略图获取失败,使用原图进行替换!', img.src, img.lossless)
            img.src = img.lossless
            img.lossless = null
          } else {
            this.setCover(cover, null, null) // preview 会自动处理 error,不必理会
            cover.error.style.display = 'block'
            api.logger.error(e)
          }
          peCover()
        })
        if (gm.runtime.realtimeStyle !== 'disable') {
          api.base.addStyle(gm.runtime.realtimeStyle)
        }
        if (gm.config.disableContextMenu) {
          this.disableContextMenu(cover)
        } else if (gm.runtime.realtimeQuality !== 'best') {
          // 将缩略图替换为原图,以便右键菜单获取到正确的图像
          cover.img.addEventListener('mousedown', /** @param {MouseEvent} e */ e => {
            const { img } = cover
            if (e.button === 2 && img.lossless && img.src !== img.lossless) {
              img.src = img.lossless
              img.lossless = null
            }
          })
        }
        return cover
      },

      /**
       * 禁用右键菜单
       * @param {HTMLElement} target 目标元素
       */
      disableContextMenu(target) {
        target.addEventListener('contextmenu', e => e.preventDefault())
      },

      /**
       * 克隆事件
       *
       * 直接复用 event 在某些情况下会出问题,克隆可避免之。
       * @param {Event} event 原事件
       * @param {string[]} attrNames 需克隆的属性值
       * @returns {Event} 克隆事件
       */
      cloneEvent(event, attrNames = []) {
        const cloned = new Event(event.type)
        for (const name of attrNames) {
          cloned[name] = event[name]
        }
        return cloned
      },

      /**
       * @callback coverInteractionPre 封面交互前置处理
       * @param {Event} 事件
       * @returns {boolean | Promise<boolean>} 本次是否启用代理
       */
      /**
       * 代理封面交互
       *
       * 全面接管一切用户交互引起的行为,默认链接点击行为除外
       * @param {HTMLElement} target 目标元素
       * @param {coverInteractionPre} pre 封面交互前置处理
       */
      proxyCoverInteraction(target, pre) {
        const _self = this
        addEventListeners()

        async function main(event) {
          if (!await pre(event)) return
          removeEventListeners()
          if (event.type === 'mousedown') {
            // 鼠标左键点击链接可通过 click 拦截但没必要,中键点击链接无法通过 js 拦截不过也没必要拦
            // 同样地,无法通过 mousedown 事件中让浏览器模拟出链接被左键或中键点击的结果,需手动模拟
            let needDispatch = true
            if (event.button === 0) {
              if (!gm.config.download && target.loaded) {
                window.open(target.href)
                needDispatch = false
              }
            } else if (event.button === 1) {
              if (target.loaded) {
                window.open(target.href)
                needDispatch = false
              }
            }
            if (needDispatch) {
              target.dispatchEvent(_self.cloneEvent(event, ['button', 'ctrlKey']))
            }
          } else if (event.type === 'mouseenter') {
            target.dispatchEvent(_self.cloneEvent(event))
          }
          addEventListeners()
        }

        function addEventListeners() {
          target.addEventListener('mousedown', main, true)
          if (gm.runtime.preview) {
            target.addEventListener('mouseenter', main, true)
          }
        }

        function removeEventListeners() {
          target.removeEventListener('mousedown', main, true)
          if (gm.runtime.preview) {
            target.removeEventListener('mouseenter', main, true)
          }
        }
      },
    }

    async initVideo() {
      const app = await api.wait.$('#app')
      const atr = await api.wait.$('#arc_toolbar_report, #playlistToolbar') // 无论如何都卡一下时间
      await api.wait.waitForConditionPassed({
        condition: () => app.__vue__,
      })

      let cover = null
      if (gm.runtime.layer === 'legacy') {
        cover = document.createElement('a')
        cover.textContent = '获取封面'
        cover.className = `${gm.id}-video-cover-btn`
        if (gm.runtime.preview) {
          cover.style.cursor = 'none'
        }

        const gm395456 = atr.querySelector('[id|=gm395456]') // 确保与其他脚本配合时组件排列顺序不会乱
        const right = atr.querySelector('.toolbar-right, .video-toolbar-right')
        if (right) {
          cover.classList.add('video-toolbar-right-item')
          if (gm395456) {
            gm395456.after(cover)
          } else {
            right.prepend(cover)
          }
        } else { // 旧版
          cover.dataset.toolbarVersion = 'old'
          cover.classList.add('appeal-text')
          if (gm395456) {
            gm395456.before(cover)
          } else {
            atr.append(cover)
          }
        }
        this.method.disableContextMenu(cover)
      } else {
        cover = await this.method.createRealtimeCover()
      }
      const preview = gm.runtime.preview && this.method.createPreview(cover)
      this.method.setHintText(cover, gm.const.hintText)

      if (api.base.urlMatch(gm.regex.page_videoNormalMode)) {
        api.wait.executeAfterElementLoaded({
          selector: 'meta[itemprop=image]',
          base: document.head,
          subtree: false,
          repeat: true,
          timeout: 0,
          onError: e => {
            this.method.setCover(cover, preview, null)
            api.logger.error(e)
          },
          callback: meta => this.method.setCover(cover, preview, meta.content),
        })
      } else {
        if (gm.runtime.layer === 'legacy') {
          this.method.proxyCoverInteraction(cover, async event => {
            try {
              const vid = this.method.getVid()
              if (cover._coverId === vid.id) return false
              // 在异步等待前拦截,避免逻辑倒置
              event.stopPropagation()
              const url = await getCover(vid)
              this.method.setCover(cover, preview, url)
            } catch (e) {
              event.stopPropagation()
              this.method.setCover(cover, preview, null)
              api.logger.error(e)
            }
            return true
          })
        } else {
          const main = async () => {
            try {
              const vid = this.method.getVid()
              if (cover._coverId === vid.id) return
              const url = await getCover(vid)
              this.method.setCover(cover, preview, url)
            } catch (e) {
              this.method.setCover(cover, preview, null)
              api.logger.error(e)
            }
          }

          setTimeout(main)
          window.addEventListener('urlchange', main)
        }

        const getCover = async (vid = this.method.getVid()) => {
          if (cover._coverId !== vid.id) {
            const resp = await api.web.request({
              url: gm.url.api_videoInfo(vid.id, vid.type),
            }, { check: r => r.code === 0 })
            cover._coverUrl = resp.data.pic ?? ''
            cover._coverId = vid.id
          }
          return cover._coverUrl
        }
      }
    }

    async initBangumi() {
      const app = await api.wait.$('#app')
      const tm = await api.wait.$('#toolbar_module') // 无论如何都卡一下时间
      await api.wait.waitForConditionPassed({
        condition: () => app.__vue__,
      })

      let cover = null
      if (gm.runtime.layer === 'legacy') {
        cover = document.createElement('a')
        cover.textContent = '获取封面'
        cover.className = `${gm.id}-bangumi-cover-btn`
        if (gm.runtime.preview) {
          cover.style.cursor = 'none'
        }
        tm.append(cover)
        this.method.disableContextMenu(cover)
      } else {
        cover = await this.method.createRealtimeCover()
      }
      const preview = gm.runtime.preview && this.method.createPreview(cover)
      this.method.setHintText(cover, gm.const.hintText)

      if (gm.config.bangumiSeries) {
        const setCover = img => this.method.setCover(cover, preview, img.src.replace(/@[^@]*$/, ''))
        api.wait.$('.media-cover img').then(img => {
          setCover(img)
          const ob = new MutationObserver(() => setCover(img))
          ob.observe(img, { attributeFilter: ['src'] })
        }).catch(e => {
          this.method.setCover(cover, preview, null)
          api.logger.error(e)
        })
      } else {
        if (gm.runtime.layer === 'legacy') {
          this.method.proxyCoverInteraction(cover, event => {
            try {
              const bgmid = this.method.getBgmid()
              if (cover._coverId === bgmid.id) return false
              const url = getCover(bgmid)
              this.method.setCover(cover, preview, url)
            } catch (e) {
              this.method.setCover(cover, preview, null)
              api.logger.error(e)
            }
            event.stopPropagation()
            return true
          })
        } else {
          const main = () => {
            try {
              const bgmid = this.method.getBgmid()
              if (cover._coverId === bgmid.id) return
              const url = getCover(bgmid)
              this.method.setCover(cover, preview, url)
            } catch (e) {
              this.method.setCover(cover, preview, null)
              api.logger.error(e)
            }
          }

          setTimeout(main)
          window.addEventListener('urlchange', main)
        }

        const getParams = () => unsafeWindow.getPlayerExtraParams?.()
        const getCover = (bgmid = this.method.getBgmid()) => {
          if (cover._coverId !== bgmid.id) {
            const params = getParams()
            cover._coverUrl = params.epCover
            cover._coverId = bgmid.id
          }
          return cover._coverUrl
        }
      }
    }

    async initLive() {
      const container = await api.wait.$('#head-info-vm .right-ctnr, #head-info-vm .upper-right-ctnr')
      // 这里再获取 hiVm,提前获取到的 hiVm 有可能会被替换成新的
      const hiVm = container.closest('#head-info-vm')
      await api.wait.waitForConditionPassed({
        condition: () => hiVm.__vue__,
      })

      const templateEl = container.firstElementChild
      const cover = document.createElement('a')
      cover.textContent = '获取封面'
      cover.className = templateEl.className
      cover.classList.add(`${gm.id}-live-cover-btn`)
      cover.setAttribute('style', templateEl.getAttribute('style'))
      if (gm.runtime.preview) {
        cover.style.cursor = 'none'
      }
      container.prepend(cover)
      this.method.disableContextMenu(cover)
      const preview = gm.runtime.preview && this.method.createPreview(cover)
      this.method.setHintText(cover, gm.const.hintText)

      this.method.proxyCoverInteraction(cover, async event => {
        try {
          if (cover.loaded) return false
          // 在异步等待前拦截,避免逻辑倒置
          event.stopPropagation()
          const url = await getCover()
          this.method.setCover(cover, preview, url)
        } catch (e) {
          event.stopPropagation()
          this.method.setCover(cover, preview, null)
          api.logger.error(e)
        }
        return true
      })

      // 避免直播间名字过长时热门榜/热门排名显示错乱
      api.base.addStyle(`
        .left-ctnr {
          margin-right: 1em;
        }
        .hot-rank-wrap {
          word-break: keep-all;
        }
      `)

      async function getCover() {
        if (!cover.loaded) {
          cover._coverUrl = await api.wait.waitForConditionPassed({
            condition: () => unsafeWindow.__NEPTUNE_IS_MY_WAIFU__?.roomInfoRes?.data?.room_info?.cover ?? unsafeWindow.__STORE__?.baseInfoRoom?.coverUrl,
            interval: 100,
            timeout: 2000,
            stopOnTimeout: true,
          })
        }
        return cover._coverUrl
      }
    }

    addStyle() {
      api.base.addStyle(`
        .${gm.id}-video-cover-btn {
          margin-right: 24px;
        }
        .${gm.id}-video-cover-btn[data-toolbar-version=old] {
          user-select: none;
          margin-right: 20px;
        }

        .${gm.id}-bangumi-cover-btn {
          float: right;
          cursor: pointer;
          font-size: 12px;
          margin-right: 16px;
          line-height: 36px;
          color: #505050;
          user-select: none;
        }
        .${gm.id}-bangumi-cover-btn:hover {
          color: #00a1d6;
        }

        .${gm.id}-live-cover-btn {
          cursor: pointer;
          color: #999999;
          user-select: none;
        }
        .${gm.id}-live-cover-btn:hover {
          color: #23ADE5;
        }

        .${gm.id}-preview {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          z-index: 1000000;
          max-width: 65vw; /* 自适应宽度和高度 */
          max-height: 80vh;
          border-radius: 8px;
          display: none;
          opacity: 0;
          transition: opacity ${gm.const.fadeTime}ms ease-in-out;
          box-shadow: #000000AA 0px 3px 6px;
          pointer-events: none;
        }

        #${gmId}-realtime-cover div {
          color: gray;
          padding: 5px;
          font-size: 18px;
          text-align: center;
          user-select: none;
          display: none;
        }
      `)
    }
  }

  // B站在 2022 年的一系列更新后,无论是新版还是旧版的播放页面中,load 时点的到来晚得不合常理,如果还等到 load 再执行
  // 后续逻辑会使得脚本功能切入过慢——本来打开页面就理应能获取到封面,却要等页面加载半天,这着实是一个非常糟糕的体验。
  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', main) : main()

  function main() {
    script = new Script()
    webpage = new Webpage()

    script.init()
    script.initScriptMenu()
    webpage.addStyle()
    api.base.initUrlchangeEvent()

    if (api.base.urlMatch([gm.regex.page_videoNormalMode, gm.regex.page_videoWatchlaterMode, gm.regex.page_listMode])) {
      webpage.initVideo()
    } else if (api.base.urlMatch(gm.regex.page_bangumi)) {
      webpage.initBangumi()
    } else if (api.base.urlMatch(gm.regex.page_live)) {
      webpage.initLive()
    }
  }
})()