Greasy Fork is available in English.
爬取主流视频网站弹幕显示到第三方播放器中
// ==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) } }) }