弹幕插件

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

// ==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)
    }
  })
}