Greasy Fork

来自缓存

Greasy Fork is available in English.

B站封面获取

B站视频播放页(普通模式、稍后再看模式)、番剧播放页、直播间添加获取封面的按钮

当前为 2020-08-01 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @id              BilibiliCover@Laster2800
// @name            B站封面获取
// @version         4.5.3.20200731
// @namespace       laster2800
// @author          Laster2800
// @description     B站视频播放页(普通模式、稍后再看模式)、番剧播放页、直播间添加获取封面的按钮
// @icon            https://www.bilibili.com/favicon.ico
// @include         *://www.bilibili.com/video/*
// @include         *://www.bilibili.com/bangumi/play/*
// @include         *://www.bilibili.com/medialist/play/watchlater
// @include         *://www.bilibili.com/medialist/play/watchlater/*
// @include         *://live.bilibili.com/*
// @exclude         *://live.bilibili.com/
// @exclude         *://live.bilibili.com/*/*
// @grant           GM_addStyle
// @grant           GM_download
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_registerMenuCommand
// @grant           GM_unregisterMenuCommand
// ==/UserScript==

(function() {
  var gm = {
    id: 'gm395575',
    title: '点击保存封面或在新标签页中打开图片(可在脚本菜单中设置)。\n此外,可在脚本菜单中开启或关闭封面预览功能。\n右键点击可基于图片链接作进一步的处理,如通过“另存为”直接保存图片。',
    enable: {
      preview: true,
      download: true,
    },
    fnName: {
      preview: '封面预览',
      download: '点击下载',
    }
  }

  var createMenu = name => {
    var afterSwitch = () => !gm.enable[name] ? '开启' : '关闭'
    var id = GM_registerMenuCommand(afterSwitch() + gm.fnName[name], menuCallback)

    function menuCallback() {
      gm.enable[name] = !gm.enable[name]
      GM_setValue(name, gm.enable[name])
      GM_unregisterMenuCommand(id)
      id = GM_registerMenuCommand(afterSwitch() + gm.fnName[name], menuCallback)
    }
  }
  for (var name in gm.enable) {
    var eb = GM_getValue(name)
    gm.enable[name] = typeof eb == 'boolean' ? eb : gm.enable[name]
    createMenu(name)
  }

  if (/\/video\//.test(location.href)) {
    executeAfterConditionPassed({
      condition: () => {
        var app = document.querySelector('#app')
        var vueLoad = app && app.__vue__
        if (!vueLoad) {
          return false
        }
        return document.querySelector('#arc_toolbar_report')
      },
      callback: addVideoBtn,
    })
  } else if (/\/bangumi\/play\//.test(location.href)) {
    executeAfterConditionPassed({
      condition: () => {
        var app = document.querySelector('#app')
        var vueLoad = app && app.__vue__
        if (!vueLoad) {
          return false
        }
        return document.querySelector('#toolbar_module')
      },
      callback: addBangumiBtn,
    })
  } else if (/live\.bilibili\.com\/\d/.test(location.href)) {
    executeAfterConditionPassed({
      condition: () => {
        var hiVm = document.querySelector('#head-info-vm')
        var vueLoad = hiVm && hiVm.__vue__
        if (!vueLoad) {
          return false
        }
        return hiVm.querySelector('.room-info-upper-row .upper-right-ctnr')
      },
      callback: addLiveBtn,
    })
  } else if (/\/medialist\/play\/watchlater(?=\/|$)/.test(location.href)) {
    executeAfterConditionPassed({
      condition: () => {
        var app = document.querySelector('#app')
        var vueLoad = app && app.__vue__
        if (!vueLoad) {
          return false
        }
        return app.querySelector('#playContainer .left-container .play-options .play-options-more')
      },
      callback: addWatchlaterVideoBtn,
    })
  }

  function addVideoBtn(atr) {
    var coverMeta = document.querySelector('head meta[itemprop=image]')
    var coverUrl = coverMeta && coverMeta.content
    var cover = document.createElement('a')
    var errorMsg = '获取失败,若非网络问题请提供反馈'
    cover.innerText = '获取封面'
    cover.target = '_blank'
    if (coverUrl) {
      cover.href = coverUrl
      addDownloadEvent(cover)
      createPreview(cover).src = coverUrl
    } else {
      cover.onclick = () => alert(errorMsg)
    }
    cover.title = gm.title || errorMsg
    cover.className = 'appeal-text'

    // 确保与其他脚本配合时相关 UI 排列顺序不会乱
    var gm395456 = atr.querySelector('[id|=gm395456]')
    if (gm395456) {
      atr.insertBefore(cover, gm395456)
    } else {
      atr.appendChild(cover)
    }
  }

  function addBangumiBtn(tm) {
    var coverMeta = document.querySelector('head meta[property="og:image"]')
    var coverUrl = coverMeta && coverMeta.content
    var cover = document.createElement('a')
    var errorMsg = '获取失败,若非网络问题请提供反馈'
    cover.innerText = '获取封面'
    cover.target = '_blank'
    if (coverUrl) {
      cover.href = coverUrl
      addDownloadEvent(cover)
      createPreview(cover).src = coverUrl
    } else {
      cover.onclick = () => alert(errorMsg)
    }
    cover.title = gm.title || errorMsg
    cover.className = `${gm.id}_cover_btn`
    tm.appendChild(cover)

    GM_addStyle(`
  .${gm.id}_cover_btn {
      float: right;
      cursor: pointer;
      font-size: 12px;
      margin-right: 16px;
      line-height: 36px;
      color: #505050;
  }
  .${gm.id}_cover_btn:hover {
      color: #00a1d6;
  }
    `)
  }

  function addLiveBtn(urc) {
    try {
      var data = unsafeWindow.__NEPTUNE_IS_MY_WAIFU__.baseInfoRes.data
      var coverUrl = data.user_cover
      var kfUrl = data.keyframe
    } catch (e) {
      console.error(e)
    }
    var cover = document.createElement('a')
    cover.innerText = '获取封面'
    cover.target = '_blank'
    if (coverUrl) {
      cover.href = coverUrl
      cover.title = gm.title
      addDownloadEvent(cover)
      createPreview(cover).src = coverUrl
    } else if (kfUrl) {
      cover.href = kfUrl
      cover.title = '直播间没有设置封面,或者因不明原因无法获取到封面,点击获取关键帧:\n' + kfUrl
      addDownloadEvent(cover)
      createPreview(cover).src = kfUrl
    } else {
      var errorMsg = '获取失败,若非网络问题请提供反馈'
      cover.onclick = () => alert(errorMsg)
      cover.title = errorMsg
    }
    cover.className = `${gm.id}_cover_btn`
    urc.insertBefore(cover, urc.firstChild)

    GM_addStyle(`
  .${gm.id}_cover_btn {
      cursor: pointer;
      color: rgb(153, 153, 153);
  }
  .${gm.id}_cover_btn:hover {
      color: #23ade5;
  }
    `)
  }

  function addWatchlaterVideoBtn(pom) {
    var bus = {}
    var cover = document.createElement('a')
    var errorMsg = '获取失败,可能是因为该视频已经移除出稍后再看;也可能是网络原因,可刷新并尝试。如果还是不行请联系脚本作者……'
    cover.innerText = '获取封面'
    cover.target = '_blank'
    cover.className = `${gm.id}_cover_btn`
    cover.onclick = e => e.stopPropagation()
    var preview = createPreview(cover)

    // 确保与其他脚本配合时相关 UI 排列顺序不会乱
    var gmContainer = pom.querySelector('[id=gm-container]')
    if (!gmContainer) {
      gmContainer = pom.appendChild(document.createElement('span'))
      gmContainer.id = 'gm-container'
      gmContainer.style.float = 'left'
    }
    gmContainer.appendChild(cover)

    executeAfterConditionPassed({
      condition: () => {
        var app = document.querySelector('#app')
        var vueLoad = app && app.__vue__
        if (!vueLoad) {
          return false
        }
        var playContainer = app.querySelector('#playContainer')
        if (playContainer.__vue__.playCover) {
          return playContainer
        }
      },
      callback: playContainer => {
        bus.playContainer = playContainer
        bus.cover = playContainer.__vue__.playCover
        setCover(bus.cover)

        createLocationchangeEvent()
        window.addEventListener('locationchange', function() {
          updateCoverUrl()
        })
      },
      timeout: 2000,
      onTimeout: () => setCover(false)
    })

    var updateCoverUrl = () => {
      executeAfterConditionPassed({
        condition: () => {
          var cover = bus.playContainer.__vue__.playCover
          if (cover && cover != bus.cover) {
            return cover
          }
        },
        callback: cover => {
          bus.cover = cover
          setCover(cover)
        },
        timeout: 2000,
        onTimeout: () => setCover(false)
      })
    }

    var setCover = coverUrl => {
      if (coverUrl) {
        cover.href = coverUrl
        preview.src = coverUrl
        addDownloadEvent(cover)
      } else {
        cover.href = ''
        preview.src = ''
        cover.onclick = () => alert(errorMsg)
      }
      cover.title = gm.title || errorMsg
    }

    var createLocationchangeEvent = () => {
      // 创建 locationchange 事件
      // https://stackoverflow.com/a/52809105
      if (!unsafeWindow._createLocationchangeEvent) {
        history.pushState = (f => function pushState() {
          var ret = f.apply(this, arguments)
          window.dispatchEvent(new Event('pushstate'))
          window.dispatchEvent(new Event('locationchange'))
          return ret
        })(history.pushState)
        history.replaceState = (f => function replaceState() {
          var ret = f.apply(this, arguments)
          window.dispatchEvent(new Event('replacestate'))
          window.dispatchEvent(new Event('locationchange'))
          return ret
        })(history.replaceState)
        window.addEventListener('popstate', () => {
          window.dispatchEvent(new Event('locationchange'))
        })
        unsafeWindow._createLocationchangeEvent = true
      }
    }

    GM_addStyle(`
  .${gm.id}_cover_btn {
      cursor: pointer;
      float: right;
      margin-right: 1em;
      font-size: 12px;
      color: #757575;
  }
  .${gm.id}_cover_btn:hover {
    color: #23ade5;
  }
    `)
  }

  /**
   * 下载图片
   * @param {HTMLElement} target 图片按钮元素
   */
  function addDownloadEvent(target) {
    target.onclick = function(e) {
      if (gm.enable.download) {
        e.preventDefault()
        target.dispatchEvent(new Event('mouseleave'))
        target.disablePreview = true
        GM_download(this.href, document.title || 'Cover')
      }
    }
  }

  /**
   * 创建预览元素
   * @param {HTMLElement} target 触发元素
   * @returns {HTMLImageElement}
   */
  function createPreview(target) {
    var preview = document.body.appendChild(document.createElement('img'))
    preview.className = `${gm.id}_preview`

    var fadeTime = 200
    var browserSyncTime = 10
    var antiConflictTime = 20

    var fadeIn = () => {
      preview.style.display = 'unset'
      setTimeout(() => {
        preview.style.opacity = '1'
      }, browserSyncTime)
    }
    var fadeOut = callback => {
      preview.style.opacity = '0'
      setTimeout(() => {
        preview.style.display = 'none'
        callback && callback()
      }, fadeTime)
    }
    var disablePreviewTemp = () => {
      target.disablePreview = true
      setTimeout(() => {
        if (!target.mouseOver) {
          target.disablePreview = false
        }
      }, 80)
    }

    target.addEventListener('mouseenter', function() {
      if (gm.enable.preview) {
        this.mouseOver = true
        if (this.disablePreview) {
          return
        }
        setTimeout(() => {
          preview.src && fadeIn()
        }, antiConflictTime)
      }
    })
    target.addEventListener('mouseleave', function() {
      if (gm.enable.preview) {
        this.mouseOver = false
        if (this.disablePreview) {
          this.disablePreview = false
          return
        }
        setTimeout(() => {
          preview.src && !preview.mouseOver && fadeOut()
        }, antiConflictTime)
      }
    })

    var startPos // 鼠标进入预览时的初始坐标
    preview.onmouseenter = function() {
      this.mouseOver = true
    }
    preview.onmouseleave = function() {
      this.mouseOver = false
      startPos = undefined
      setTimeout(() => {
        preview.src && fadeOut()
      }, antiConflictTime)
    }
    preview.onclick = function() {
      if (this.src) {
        if (gm.enable.download) {
          GM_download(this.src, document.title)
        } else {
          window.open(this.src)
        }
        fadeOut(disablePreviewTemp)
      }
    }
    preview.addEventListener('wheel', function() {
      // 滚动时关闭预览,优化用户体验
      fadeOut(disablePreviewTemp)
    })
    preview.addEventListener('mousemove', function(e) {
      // 鼠标移动一段距离关闭预览,优化用户体验
      if (startPos) {
        var dSquare = (startPos.x - e.clientX) ** 2 + (startPos.y - e.clientY) ** 2
        if (dSquare > 20 ** 2) { // 20px
          fadeOut(disablePreviewTemp)
        }
      } else {
        startPos = {
          x: e.clientX,
          y: e.clientY,
        }
      }
    })

    GM_addStyle(`
  .${gm.id}_preview {
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      z-index: 142857;
      max-width: 60vw; /* 自适应宽度和高度 */
      max-height: 100vh;
      display: none;
      transition: opacity ${fadeTime}ms ease-in-out;
      opacity: 0;
      cursor: pointer;
  }
    `)

    return preview
  }

  /**
   * 在条件满足后执行操作
   *
   * 当条件满足后,如果不存在终止条件,那么直接执行 `callback(result)`。
   *
   * 当条件满足后,如果存在终止条件,且 `stopTimeout` 大于 0,则还会在接下来的 `stopTimeout` 时间内判断是否满足终止条件,称为终止条件的二次判断。
   * 如果在此期间,终止条件通过,则表示依然不满足条件,故执行 `onStop()` 而非 `callback(result)`。
   * 如果在此期间,终止条件一直失败,则顺利通过检测,执行 `callback(result)`。
   *
   * @param {Object} options 选项
   * @param {() => *} options.condition 条件,当 `condition()` 返回的 `result` 为真值时满足条件
   * @param {(result) => void} [options.callback] 当满足条件时执行 `callback(result)`
   * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
   * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
   * @param {() => void} [options.onTimeout] 检测超时时执行 `onTimeout()`
   * @param {() => *} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
   * @param {() => void} [options.onStop] 终止条件达成时执行 `onStop()`(包括终止条件的二次判断达成)
   * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
   * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
   * @param {number} [options.timePadding=0] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
   */
  function executeAfterConditionPassed(options) {
    const defaultOptions = {
      callback: result => console.log(result),
      interval: 100,
      timeout: 5000,
      onTimeout: null,
      stopCondition: null,
      onStop: null,
      stopInterval: 50,
      stopTimeout: 0,
      timePadding: 0,
    }
    options = {
      ...defaultOptions,
      ...options
    }

    let tid
    let cnt = 0
    const maxCnt = (options.timeout - options.timePadding) / options.interval
    const task = () => {
      const result = options.condition()
      const stopResult = options.stopCondition && options.stopCondition()
      if (stopResult) {
        clearInterval(tid)
        options.onStop && options.onStop.call(options)
      } else if (++cnt > maxCnt) {
        clearInterval(tid)
        options.onTimeout && options.onTimeout.call(options)
      } else if (result) {
        clearInterval(tid)
        if (options.stopCondition && options.stopTimeout > 0) {
          this.executeAfterConditionPassed({
            condition: options.stopCondition,
            callback: options.onStop,
            interval: options.stopInterval,
            timeout: options.stopTimeout,
            onTimeout: () => options.callback.call(options, result)
          })
        } else {
          options.callback.call(options, result)
        }
      }
    }
    setTimeout(() => {
      tid = setInterval(task, options.interval)
      task()
    }, options.timePadding)
  }
})()