Greasy Fork is available in English.
爬取主流视频网站弹幕显示到第三方播放器中
Fra og med
// ==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.nodeNameif (nodeName === '#text') continueif (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 + 15const danmuUrl = 'http://mfm.video.qq.com/danmu?timestamp=' + offset + '&target_id=' + targetIdreturn await 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 await 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 await 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.documentElementif (!docRoot) return falseconst listenNodeList = []// 获取MutationObserver,兼容低版本的浏览器const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver// 创建Observerconst 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 = 0if (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 = nullthis.videoUrl = ''this.offsets = []this.danmus = []this.state = 'close'this.cTime = 0}addEvent() {if (this.player == null) returnGM_addValueChangeListener('state', (name, old_value, new_value, remote) => {this.state = new_valueif (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.currentTimeconsole.log('显示弹幕 ')this.start()}) */// this.player.addEventListener('play', event => {// this.play()// })this.player.addEventListener('timeupdate', event => this.play())}use(player) {this.player = playerthis.videoUrl = ''if (this.player == null) returnthis.state = 'close'this.addEvent()}pause() {if (this.state == 'close') returnthis.state = 'pause'this.player.parentNode.querySelectorAll('.danmu').forEach(item => (item.style.animationPlayState = 'paused'))}async play() {this.videoUrl = GM_getValue('videoUrl', '')if (this.videoUrl == '') returnif (this.state == 'close') returnif (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) returnif (this.cTime === currentTime) returnthis.cTime = currentTimeconst 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.playerconst 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) returnconst 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.contentdm.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) returnthis.player.parentNode.querySelectorAll('.danmu').forEach(item => item.remove())this.state = 'close'}}// 添加显示盒子function addFlexBox() {if (window !== window.top) returnGM_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 = itemaddDanmuCss()setTimeout(() => {new Danmuer().use(player)}, 3000)}})}