Greasy Fork

Greasy Fork is available in English.

弹幕插件

爬取主流视频网站弹幕显示到第三方播放器中

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         弹幕插件
// @namespace    https://github.com/yuaErha
// @include      http://*
// @include      https://*
// @version      0.1
// @description  爬取主流视频网站弹幕显示到第三方播放器中
// @author       Yua
// @match        http://*/*
// @match        https://*/*
// @icon         127.0.0.1
// @connect      *
// @require      https://lib.baomitu.com/pako/2.0.4/pako.es5.min.js
// @run-at       document-body
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// ==/UserScript==

// Ajax 封装
const xhr = option =>
  new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      ...option,
      onerror: reject,
      onload: resolve
    })
  })

/**
 * 
 * @param {Document} xml  -xml Dom
 * @return {string} json  -转换后的json数据
 */
function xml2json(xml) {
  try {
    let obj = {}
    if (xml.childElementCount > 0) {
      for (const node of xml.childNodes) {
        const nodeName = node.nodeName
        if (nodeName === '#text') continue
        if (typeof obj[nodeName] == 'undefined') {
          obj[nodeName] = xml2json(node)
        } else {
          if (typeof obj[nodeName].push == 'undefined') {
            obj[nodeName] = []
            obj[nodeName].push(obj[nodeName])
          }
          obj[nodeName].push(xml2json(node))
        }
      }
    } else {
      obj = Number(xml.textContent) + '' === 'NaN' ? xml.textContent : Number(xml.textContent)
    }
    return obj
  } catch (e) {
    console.log(e.message)
    return {}
  }
}
/**
 * 常见的Danmu解析器 | 腾讯视频,爱奇艺,优酷视频,芒果TV...
 */
const DanmuParser = {
  async tencentParse(url, offset = 0) {
    const { vid } = await xhr({ method: 'GET', url }).then(resp => JSON.parse(resp.responseText.match(/var VIDEO_INFO = (\{.*?\})/m)[1]))
    const targetId = await xhr({
      method: 'GET',
      url: 'http://bullet.video.qq.com/fcgi-bin/target/regist?otype=json&vid=' + vid
    }).then(resp => resp.responseText.match(/targetid=(\d+)/m)[1])
    // const timestamp = Math.floor(timepoint / 30)
    // const offset = 30 * timestamp + 15
    const danmuUrl = 'http://mfm.video.qq.com/danmu?timestamp=' + offset + '&target_id=' + targetId
    return xhr({ method: 'GET', url: danmuUrl })
      .then(resp => {
        let comments = JSON.parse(resp.responseText)['comments']
        return comments.map(
          item =>
            new Object({
              content: item['content'],
              name: item['opername'],
              time: item['timepoint']
            })
        )
      })
      .catch(err => [])
  },
  async iqiyiParse(url, offset = 0) {
    const { tvId } = await xhr({ method: 'GET', url }).then(resp => JSON.parse(resp.responseText.match(/:page-info='(.*?)'/m)[1]))
    // const offset = Math.ceil(timepoint / 60 / 5)
    const danmuUrl = `https://cmts.iqiyi.com/bullet/${String(tvId).substr(String(tvId).length - 4, 2)}/${String(tvId).substr(String(tvId).length - 2, 2)}/${tvId}_300_${offset}.z`
    return xhr({
      method: 'GET',
      url: danmuUrl,
      responseType: 'arraybuffer'
    })
      .then(resp => {
        const responseArray = new Uint8Array(resp.response)
        const responseXMl = new TextDecoder().decode(pako.ungzip(responseArray)).replace(/&#\d{2};/g, '')
        const parser = new DOMParser()
        const xmlDoc = parser.parseFromString(responseXMl, 'text/xml')
        return Array.from(xmlDoc.querySelectorAll('bulletInfo')).map(node => {
          const item = xml2json(node)
          return new Object({
            content: item['content'],
            name: item['userInfo']['name'] || '',
            time: item['showTime']
          })
        })
      })
      .catch(err => [])
  },
  async youkuParse(url, offset = 0) {
    const { videoId, seconds } = await xhr({ method: 'GET', url }).then(resp => {
      let data = resp.responseText.match(/\}\s*window\.PageConfig\s*=\s*([\s\S]*?);\s*var/m)[1]
      eval(`data = ${data}`)
      return data
    })
    const danmuUrl = `https://service.danmu.youku.com/list?mat=${offset}&ct=1001&iid=${videoId}`
    console.log(danmuUrl)
    // await xhr({ method: 'GET', url: danmuUrl }).then(resp => {
    //     console.log(resp.responseText)
    // })

    // console.log(videoId)
  },
  async mgtvParse(url, offset = 0) {
    const splits = url.split('/')
    const cid = splits[4]
    const vid = splits[5].split('.')[0]
    // const offset = Math.floor(timepoint / 60)
    const danmuUrl = `https://galaxy.bz.mgtv.com/rdbarrage?version=3.0.0&vid=${vid}&cid=${cid}&time=${60 * 1000 * offset}`
    return xhr({ method: 'GET', url: danmuUrl })
      .then(resp => {
        const comments = JSON.parse(resp.responseText)['data']['items']
        return comments.map(
          item =>
            new Object({
              content: item['content'],
              name: '',
              time: item['time']
            })
        )
      })
      .catch(err => [])
  },
  async parseUrl(url, offset = 0) {
    let danmus = []
    if (url.includes('v.qq.com')) {
      danmus = this.tencentParse(url, offset)
    } else if (url.includes('www.iqiyi.com')) {
      danmus = this.iqiyiParse(url, offset)
    } else if (url.includes('v.youku.com')) {
      danmus = this.youkuParse(url, offset)
    } else if (url.includes('www.mgtv.com')) {
      danmus = this.mgtvParse(url, offset)
    } else if (url.includes('www.bilibili.com')) {
    }
    return danmus
  }
}

// 监听Document以及frame下的 video元素
function ready(selector, fn) {
  const docRoot = window.document.documentElement
  if (!docRoot) return false
  const listenNodeList = []
  // 获取MutationObserver,兼容低版本的浏览器
  const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver
  // 创建Observer
  const Observer = new MutationObserver(mutations => {
    docRoot.querySelectorAll(selector).forEach(item => {
      if (listenNodeList.includes(item)) {
      } else {
        listenNodeList.push(item)
        fn(item)
        Observer.disconnect()
      }
    })
  })
  // 获取dom元素
  Observer.observe(docRoot, {
    childList: true,
    subtree: true
  })
}

/**
 * 弹幕数组随机排序
 */
function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[array[i], array[j]] = [array[j], array[i]]
  }
}

/**
 * 计算偏移量
 * @param {String} url    -视频链接
 * @param {int} timepoint -时间戳
 * @return {int} offset   -偏移量
 */
function calcOffset(url, timepoint) {
  let offset = 0
  if (url.includes('v.qq.com')) {
    const timestamp = Math.floor(timepoint / 30)
    offset = 30 * timestamp + 15
  } else if (url.includes('www.iqiyi.com')) {
    offset = Math.ceil(timepoint / 60 / 5)
  } else if (url.includes('v.youku.com')) {
  } else if (url.includes('www.mgtv.com')) {
    offset = Math.floor(timepoint / 60)
  } else if (url.includes('www.bilibili.com')) {
  }
  return offset
}

// 生成随机显示的时间单位
function randomNum(minNum, maxNum) {
  switch (arguments.length) {
    case 1:
      return parseInt(Math.random() * minNum + 1, 10)
    case 2:
      return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10)
    default:
      return 0
  }
}

// 弹幕颜色
const colors = ['#E91E63', '#FFEB3B', '#F44336', '#2196F3', '#FFB74D']

/**
 * 弹幕类 监听播放器事件: 播放 暂停 全屏...
 */
class Danmuer {
  constructor() {
    this.player = null
    this.videoUrl = ''
    this.offsets = []
    this.danmus = []
    this.state = 'close'
    this.cTime = 0
  }
  addEvent() {
    if (this.player == null) return

    GM_addValueChangeListener('state', (name, old_value, new_value, remote) => {
      this.state = new_value
      if (this.state === 'close') this.close()
    })

    // 监听事件
    /* document.addEventListener('fullscreenchange', function (event) {
      if (document.fullscreenElement !== null) {
        // console.log(document.fullscreenElement)
      }
    }) */

    this.player.addEventListener('pause', event => this.pause())
    /*     this.player.addEventListener('playing', event => {
      const currentTime = event.target.currentTime
      console.log('显示弹幕  ')
      this.start()
    }) */
    // this.player.addEventListener('play', event => {
    //   this.play()
    // })
    this.player.addEventListener('timeupdate', event => this.play())
  }
  use(player) {
    this.player = player
    this.videoUrl = ''
    if (this.player == null) return
    this.state = 'close'
    this.addEvent()
  }
  pause() {
    if (this.state == 'close') return
    this.state = 'pause'
    this.player.parentNode.querySelectorAll('.danmu').forEach(item => (item.style.animationPlayState = 'paused'))
  }
  async play() {
    this.videoUrl = GM_getValue('videoUrl', '')
    if (this.videoUrl == '') return
    if (this.state == 'close') return
    if (this.state == 'pause') {
      this.player.parentNode.querySelectorAll('.danmu').forEach(item => (item.style.animationPlayState = 'running'))
    }
    this.state = 'play'
    const currentTime = Math.floor(this.player.currentTime)
    if (currentTime % 2 == 0) return
    if (this.cTime === currentTime) return
    this.cTime = currentTime
    const offset = calcOffset(this.videoUrl, currentTime)
    if (!this.offsets.includes(offset)) {
      this.offsets.push(offset)
      const danmus = await DanmuParser.parseUrl(this.videoUrl, offset)
      this.danmus.push(...danmus)
    }

    const { clientWidth, clientHeight } = this.player
    const track = Math.ceil(clientHeight / 3 / 20)

    let arr = this.danmus.filter(item => {
      if (item.time != currentTime && item.time != currentTime + 1) {
        return false
      }
      item.content = item.content.replace(/\[.*?\]/g, '').trim()
      return item.content.length >= 2
    }) /* .filter((item,index,array)=>{}) */
    shuffleArray(arr)
    for (let i = 0; i < arr.length; i++) {
      if (i >= 3) return
      const item = arr[i]
      const dm = document.createElement('div')
      dm.setAttribute('class', 'danmu')
      if (Math.random() * 10 < 2) {
        dm.style.setProperty('color', colors[Math.floor(Math.random() * colors.length)], 'important')
      } else {
        dm.style.setProperty('color', '#ffffff', 'important')
      }
      // dm.style.setProperty('color', '#ffffff', 'important')
      dm.style.transform = `translateX(${clientWidth + 60}px)`
      // dm.style.animationDelay = `${Math.ceil(Math.random() * 5 + 1)}s`
      dm.style.marginTop = `${20 * randomNum(track) /*  + randomNum(10) */}px`
      /*  if (item.name === '') {
        dm.innerText = item.content
      } else {
        dm.innerText = item.name + ': ' + item.content
      } */
      dm.innerText = item.content
      dm.style.animationDuration = `${18 + randomNum(2)}s`
      // dm.style.animationDuration =Math.ceil( (clientWidth + 80)/120) + 's'
      dm.addEventListener('webkitAnimationEnd', event => dm.remove(), false)
      this.player.parentNode.appendChild(dm)
    }
  }
  close() {
    if (this.player == null) return
    this.player.parentNode.querySelectorAll('.danmu').forEach(item => item.remove())
    this.state = 'close'
  }
}

// 添加显示盒子
function addFlexBox() {
  if (window !== window.top) return
  GM_addStyle(`
  #danmu-box{
    position: fixed;
    top: 60px;
    right: 60px;
    z-index: 999999;
    height: auto;
    width: fit-content;
    display: flex;
    justify-content: center;
    align-items: center;
    box-shadow: 0 0 5px rgba(0, 0, 0, .3);
  }
  #danmu-box #danmu-url{
      margin: 0;
      padding: 0;
      width: 0;
      height: 24px;  
      background:rgb(244, 245, 245);  
      outline:none;  
      border:1px #1e80ff solid;
      box-sizing: border-box;
      transition: all .2s linear;
  }
  #danmu-box:hover>#danmu-url{
      width: 80px;
      padding: 0 4px;
  }
  #danmu-box #danmu-btn{
      margin: 0;
      padding: 0;
      height: 24px;
      width: 24px;
      cursor: pointer;
      color: #ffffff;
      background:#1e80ff;
      outline:none;  
      border:0px;
  }`)
  try {
    let dBox = document.createElement('div')
    dBox.setAttribute('id', 'danmu-box')
    let dUrl = document.createElement('input')
    dUrl.setAttribute('id', 'danmu-url')
    dUrl.setAttribute('type', 'text')
    dUrl.setAttribute('placeholder', 'http://')
    let dBtn = document.createElement('input')
    dBtn.setAttribute('id', 'danmu-btn')
    dBtn.setAttribute('type', 'button')
    dBtn.setAttribute('value', '开')
    GM_setValue('state', 'close')

    dBtn.addEventListener(
      'dblclick',
      event => {
        if (dBtn.value === '弹') {
          dBtn.value = '开'
          GM_setValue('state', 'close')
        } else {
          dBtn.value = '弹'
          GM_setValue('state', 'play')
        }
      },
      false
    )
    dUrl.addEventListener(
      'input',
      event => GM_setValue('videoUrl', dUrl.value.trim()),
      false
    )
    dBox.appendChild(dUrl)
    dBox.appendChild(dBtn)
    document.querySelector('body').appendChild(dBox)
  } catch (error) {
    console.log(error)
  }
}

// 弹幕CSS3 动画
function addDanmuCss() {
  GM_addStyle(`@keyframes moveOut {
    to {
        transform: translateX(-100%);
    }
  }
  .danmu {
    z-index: 9999;
    height: auto;
    width: fit-content;
    font-size: 20px;
    position: absolute;
    top: 0;
    left: 0;
    transform: translateX(900px);
    animation-name: moveOut;
    animation-duration: 5s;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
  }
  .danmu:hover{
    cursor: pointer;
  }`)
}

function run() {
  addFlexBox()
  let player = document.querySelector('video')
  ready('video', item => {
    console.log('== 检测到了 player ==')
    if (player == null) {
      player = item
      addDanmuCss()
      setTimeout(() => {
        new Danmuer().use(player)
      }, 3000)
    }
  })
}