// ==UserScript== // @name Biliplus Evolved // @version 0.12.2 // @description 简单的B+增强脚本 // @author DeltaFlyer // @copyright 2024, DeltaFlyer(https://github.com/DeltaFlyerW) // @license MIT // @match https://*.biliplus.com/* // @run-at document-end // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect api.bilibili.com // @connect comment.bilibili.com // @connect bangumi.bilibili.com // @connect www.bilibili.com // @connect delflare505.win // @icon https://www.biliplus.com/favicon.ico // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.9.1/jszip.min.js // @namespace https://greasyfork.org/users/927887 // @downloadURL none // ==/UserScript== 'use strict'; let toastText = (function () { let html = `
` document.body.insertAdjacentHTML("beforeend", html) let bubbleContainer = document.querySelector('.df-bubble-container') function createToast(text) { console.log('toast', text) const bubble = document.createElement('div'); bubble.classList.add('df-bubble'); bubble.textContent = text; bubbleContainer.appendChild(bubble); setTimeout(() => { bubble.classList.add('df-show-bubble'); setTimeout(() => { bubble.classList.remove('df-show-bubble'); setTimeout(() => { bubbleContainer.removeChild(bubble); }, 500); // Remove the bubble after fade out }, 3000); // Show bubble for 3 seconds }, 100); // Delay before showing the bubble } return createToast })(); async function sleep(time) { await new Promise((resolve) => setTimeout(resolve, time)); } async function xhrGet(url) { function isCors(url) { if (url[0] === '/') return false // Extract the domain from the URL const urlDomain = new URL(url).hostname; // Extract the domain from the current page's URL const currentDomain = window.location.hostname; // Check if the domains are different (CORS request) return urlDomain !== currentDomain; } console.log('Get', url); if (isCors(url)) { // Use GM_xmlhttpRequest for cross-origin requests return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: url, withCredentials: true, onload: (response) => { if (response.status === 200) { resolve(response.responseText); } else { resolve(null); } }, onerror: (error) => { console.error('GM_xmlhttpRequest error:', error); resolve(null); }, }); }); } else { // Use XMLHttpRequest for same-origin requests return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.withCredentials = true; xhr.send(); xhr.onreadystatechange = async () => { if (xhr.readyState === 4) { if (xhr.status === 200) { resolve(xhr.responseText); } else { resolve(null); } } }; }); } } function downloadFile(fileName, content, type = 'text/plain;charset=utf-8') { let aLink = document.createElement('a'); let blob = content if (typeof (content) == 'string') blob = new Blob([content], {'type': type}) aLink.download = fileName; let url = URL.createObjectURL(blob) aLink.href = url aLink.click() URL.revokeObjectURL(url) } let bv2av = (function bv2av() { //https://github.com/TGSAN/bv2av.js/tree/master let s = (` const table = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'; const max_avid = 1n << 51n; const base = 58n; const bvid_len = 12n; const xor = 23442827791579n; const mask = 2251799813685247n; let tr = []; for (let i = 0; i < base; i++) { tr[table[i]] = i; } /** * avid to bvid * @param {bigint} avid * @returns {string} bvid */ function enc(avid) { let r = ['B', 'V']; let idx = bvid_len - 1n; let tmp = (max_avid | avid) ^ xor; while (tmp !== 0n) { r[idx] = table[tmp % base]; tmp /= base; idx -= 1n; } [r[3], r[9]] = [r[9], r[3]]; [r[4], r[7]] = [r[7], r[4]]; return r.join(''); } /** * bvid to avid * @param {string} bvid * @returns {bigint} avid */ function dec(bvid) { let r = bvid.split(''); [r[3], r[9]] = [r[9], r[3]]; [r[4], r[7]] = [r[7], r[4]]; let tmp = 0n; for (let char of r.slice(3)) { console.log(char) let idx = BigInt(tr[char]); tmp = tmp * base + idx; } let avid = (tmp & mask) ^ xor; return avid; }`) eval(s) return dec })(); async function aidQuery(event, init, jump) { function transform(src) { let dst = {} dst.id = src.aid dst.ver = 1 dst.aid = src.aid dst.lastupdatets = Math.floor(new Date().getTime() / 1000) dst.lastupdate = new Date().toLocaleString(); dst.pic = src.pic dst.title = src.title dst.description = src.desc dst.tid = src.tid dst.typename = src.tname dst.created = src.pubdate dst.created_at = new Date(src.pubdate * 1000).toLocaleString() dst.author = src.owner.name dst.mid = src.owner.mid dst.play = src.stat.view.toString() dst.coins = src.stat.coin dst.review = src.stat.reply dst.video_review = src.stat.danmaku dst.favorites = src.stat.favorite dst.tag = "tag_undefined" let list = [] for (let page of src.pages) { list.push({ "page": page.page, "type": page.from, "cid": page.id, "vid": undefined, "part": page.part + "_时长" + page.duration + "秒", "duration": page.duration }) } dst.list = list return dst } let aid let href = new URL(window.location.href) if (href.searchParams.has("get_info") || init) { aid = unsafeWindow.av } else { aid = window.prompt("请输入要查询的aid或bvid", unsafeWindow.av) console.log('input', aid) if (!/^\d+$/.exec(aid)) { if (/^av\d+$/.exec(aid) || /^AV\d+$/.exec(aid)) { aid = aid.substring(2) } else if (/^BV/.exec(aid)) { aid = bv2av(aid) } else { alert("请输入正确的视频号,bv号请以BV开头") return } } jump = true } if (jump) { if (href.toString().indexOf(`/all/video/av${aid}/`) === -1) { href = new URL(href.origin + `/all/video/av${aid}/`) } href.searchParams.set("get_info", '1') window.location.href = href.toString() } let url = `https://delflare505.win/getVideoInfo?aid=` + aid let response = await xhrGet(url) let body = JSON.parse(await response) console.log(body) let videoInfo = transform(body.data) unsafeWindow.videoInfo = videoInfo if (unsafeWindow.cloudmoe) { let cacheInfo = JSON.parse(JSON.stringify(videoInfo)) cacheInfo.isDetailed = true cacheInfo.keywords = "" cacheInfo = { code: 0, data: { id: cacheInfo.id, info: cacheInfo, parts: cacheInfo.list, }, } unsafeWindow.cloudmoe(cacheInfo) } else { unsafeWindow.view(videoInfo) } } sleep(200).then(() => { if (new URL(window.location.href).searchParams.has("get_info")) { aidQuery(undefined, true) } }) function domInserted(target, handle) { const observer = new MutationObserver(mutationList => { console.log("mutationList", mutationList) mutationList.filter(m => m.type === 'childList').forEach(m => { for (let e of m.addedNodes) { handle({target: e}) } }) } ); observer.observe(target, {childList: true, subtree: true}); } class CustomEventEmitter { constructor() { this.eventListeners = {}; } addEventListener(event, callback) { if (!this.eventListeners[event]) { this.eventListeners[event] = []; } this.eventListeners[event].push(callback); } removeEventListener(event, callback) { if (this.eventListeners[event]) { const index = this.eventListeners[event].indexOf(callback); if (index !== -1) { this.eventListeners[event].splice(index, 1); } } } postMessage(data) { const event = 'message' console.log(data) if (this.eventListeners[event]) { this.eventListeners[event].forEach(callback => { callback({data: data}); }); } } } class ObjectRegistry { constructor() { this.registeredObjects = new Set(); } register(obj) { if (this.registeredObjects.has(obj)) { throw new Error('Object is already registered.'); } this.registeredObjects.add(obj); } } const broadcastChannel = new CustomEventEmitter() function validateTitle(title) { if (!title) return '' return title.replace(/[\/\\\:\*\?\"\<\>\|]/g, '_') } function getPartTitle(partInfo) { let partTitle = /^\d+$/.test(partInfo.page) ? 'p' : '' partTitle += partInfo.page + ' ' + validateTitle(partInfo.part) + '_' + partInfo.cid return partTitle } function panel() { function getLocalSetting(key) { let value = GM_getValue(key) console.log('get', key, value) if (value) { return value } else { return {} } } function setDefaultValue(currentSetting, settingPanelOptions) { for (let option of settingPanelOptions) { if (option.id) { if (!currentSetting[option.id]) { currentSetting[option.id] = option.default } } else if (option.children) { for (let child of option.children) { if (child.id) { if (!currentSetting[child.id]) { currentSetting[child.id] = child.default } } } } } } function saveLocalSetting(key, value) { console.log('save', key, value) GM_setValue(key, value) } let settingPanelOptions = [{type: 'section', 'label': '抓取时段:'}, { type: 'row', children: [{ 'type': 'numberInput', 'id': 'capturePeriodStart', label: "从视频发布起第", default: 0, suffixLabel: '天开始, ', splitter: ' ' }, { 'type': 'numberInput', 'id': 'capturePeriodEnd', label: "至第", default: -1, suffixLabel: '天结束', splitter: ' ' },] }, {type: 'sectionEnd'}, { type: 'row', children: [{type: 'checkbox', id: 'splitFileByTime', label: '按时间段分割弹幕文件'}] },] let currentSetting = getLocalSetting("danmakuSetting") setDefaultValue(currentSetting, settingPanelOptions) let showSettingPanel = (function (settingPanelOptions, changeHandle) { let panelStyles = ` ` // Create the setting panel HTML string based on the provided options function createPanelHTML(options) { let html = ' '; return html; } function createSettingPanel(settingPanelOptions, changeHandle) { document.body.insertAdjacentHTML('beforeend', panelStyles); const panelHTML = createPanelHTML(settingPanelOptions); document.body.insertAdjacentHTML('beforeend', panelHTML); const panel = document.getElementById('panel'); panel.querySelector('#applyButton').addEventListener('click', () => { panel.style.display = 'none'; saveLocalSetting('danmakuSetting', currentSetting) }); const sliders = panel.querySelectorAll('.slider'); const sliderValues = panel.querySelectorAll('.slider-value'); sliders.forEach((slider, index) => { slider.addEventListener('input', () => { sliderValues[index].textContent = slider.value; changeHandle[slider.id](parseFloat(slider.value), slider.id); }); }); // Handle checkbox changes const checkboxes = panel.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(checkbox => { checkbox.addEventListener('change', () => { changeHandle[checkbox.id](Number(checkbox.checked), checkbox.id); }); }); // Handle number input changes const numberInputs = panel.querySelectorAll('.number-input'); numberInputs.forEach(input => { input.addEventListener('input', () => { const value = parseFloat(input.value); if (!isNaN(value)) { changeHandle[input.id](value, input.id); } }); }); // Handle text selector changes const textSelectors = panel.querySelectorAll('.text-selector'); textSelectors.forEach(selector => { selector.addEventListener('change', () => { changeHandle[selector.id](selector.value, selector.id); }); }); return panel } let panel = createSettingPanel(settingPanelOptions, changeHandle) panel.style.display = 'none' return function () { if (panel.style.display !== 'block') { panel.style.display = 'block' } else { panel.style.display = 'none' } } })(settingPanelOptions, { capturePeriodStart(value, id) { currentSetting[id] = value; }, capturePeriodEnd(value, id) { currentSetting[id] = value; }, splitFileByTime(value, id) { currentSetting[id] = value; }, }); async function findCidInfo() { let cid = window.prompt("请输入要查询的cid") if (!/^\d+$/.exec(cid)) { alert("请输入一个数字") return } let response = await fetch(`/api/cidinfo?cid=${cid}`) let body = await response.json() window.alert((JSON.stringify(body.data))) } (function createToolbar(config) { let html = ` ` document.body.insertAdjacentHTML('beforeend', html) const triggerArea = document.getElementById('triggerArea'); const toolbar = document.getElementById('toolbar'); let isDragging = false; let isExpanded = false; let startY = 0; let initialTop = 0; let currentSetting = getLocalSetting('dfToolbar') if (currentSetting['offsetTopPercent']) { toolbar.setAttribute('offsetTop', currentSetting['offsetTopPercent'] * window.innerHeight) } console.log('createToolbar', config) for (let option of Object.keys(config.options)) { let button = document.createElement("button") button.innerText = option button.addEventListener('click', config.options[option]) toolbar.appendChild(button) } function expandToolbar() { if (!isExpanded) { toolbar.style.left = '0'; isExpanded = true; } } function collapseToolbar() { if (isExpanded) { toolbar.style.left = '-250px'; isExpanded = false; } } triggerArea.addEventListener('mouseenter', () => { expandToolbar(); }); triggerArea.addEventListener('mouseleave', () => { collapseToolbar(); if (isDragging) { isDragging = false dragEndHandle() } }); toolbar.addEventListener('mouseenter', () => { expandToolbar(); }); toolbar.addEventListener('mouseleave', () => { if (!isDragging) { collapseToolbar(); } }); toolbar.addEventListener('mousedown', (e) => { if (e.target === toolbar) { console.log(e.type, e) isDragging = true; startY = e.clientY; initialTop = toolbar.offsetTop; } }); let draggingHandle = (e) => { if (!isDragging) return; const deltaY = e.clientY - startY; toolbar.style.top = `${initialTop + deltaY}px`; } let dragEndHandle = (e) => { if (isDragging) { isDragging = false; currentSetting.offsetTopPercent = toolbar.offsetTop / window.innerHeight saveLocalSetting('dfToolbar', currentSetting) } } window.addEventListener('mousemove', draggingHandle); window.addEventListener('mouseup', dragEndHandle); expandToolbar() setTimeout(collapseToolbar, 3000) })({ options: { "下载选项": showSettingPanel, "CID 反查": findCidInfo, "AID 查询": aidQuery } }); return currentSetting } function client() { let registeredTimestamp = new Date().getTime() console.log('biliplus script running') function getPageAid() { let aid if (/\/BV/.exec(window.location.href)) { aid = bv2av(/BV[a-zA-Z0-9]+/.exec(window.location.href)[0]) } else { aid = /av(\d+)/.exec(window.location.href)[1] } return Number(aid) } function xmlunEscape(content) { return content.replace(/, /g, ';') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/'/g, "'") .replace(/"/g, '"') } let createElement = function (sHtml) { // 创建一个可复用的包装元素 let recycled = document.createElement('div'), // 创建标签简易匹配 reg = /^<([a-zA-Z]+)(?=\s|\/>|>)[\s\S]*>$/, // 某些元素HTML标签必须插入特定的父标签内,才能产生合法元素 // 另规避:ie7-某些元素innerHTML只读 // 创建这些需要包装的父标签hash hash = { 'colgroup': 'table', 'col': 'colgroup', 'thead': 'table', 'tfoot': 'table', 'tbody': 'table', 'tr': 'tbody', 'th': 'tr', 'td': 'tr', 'optgroup': 'select', 'option': 'optgroup', 'legend': 'fieldset' }; // 闭包重载方法(预定义变量避免重复创建,调用执行更快,成员私有化) createElement = function (sHtml) { // 若不包含标签,调用内置方法创建并返回元素 if (!reg.test(sHtml)) { return document.createElement(sHtml); } // hash中是否包含匹配的标签名 let tagName = hash[RegExp.$1.toLowerCase()]; // 若无,向包装元素innerHTML,创建/截取并返回元素 if (!tagName) { recycled.innerHTML = sHtml; return recycled.removeChild(recycled.firstChild); } // 若匹配hash标签,迭代包装父标签,并保存迭代层次 let deep = 0, element = recycled; do { sHtml = '<' + tagName + '>' + sHtml + '' + tagName + '>'; deep++; } while (tagName = hash[tagName]); element.innerHTML = sHtml; // 根据迭代层次截取被包装的子元素 do { element = element.removeChild(element.firstChild); } while (--deep > -1); // 最终返回需要创建的元素 return element; } // 执行方法并返回结果 return createElement(sHtml); } async function parseVideoInfo(aid) { let videoInfo if (unsafeWindow.videoInfo && window.location.href.includes('/video/')) { return unsafeWindow.videoInfo } if (aid === undefined) { aid = getPageAid() } try { let videoPage = await xhrGet('https://www.biliplus.com/video/av' + aid + '/') videoInfo = JSON.parse(xmlunEscape(/({"id":.*?})\);/.exec(videoPage)[1])) videoInfo['aid'] = videoInfo['id'] if (!videoInfo.list || videoInfo.list.length === 0) { throw ["No part found in videoInfo Normal, try cidHistory", JSON.stringify(videoInfo, undefined, "\t")] } } catch (e) { console.log(e) let videoPage = await xhrGet('https://www.biliplus.com/all/video/av' + aid + '/') let url = /(\/api\/view_all\?.*?)'/.exec(videoPage)[1] url = 'https://www.biliplus.com' + url let data = JSON.parse(xmlunEscape(await xhrGet(url)))['data'] videoInfo = data['info'] videoInfo['list'] = data['parts'] } if (videoInfo.created) { videoInfo.videoPublishDate = videoInfo.created } console.log(videoInfo) if (window.location.href.includes('/video/')) { unsafeWindow.videoInfo = videoInfo } return videoInfo } (function searchFix() { if (window.location.href.indexOf('api/do.php') === -1) return function searchOption() { let searchField = document.querySelector("#searchField > fieldset") let searchDiv = document.querySelector("#searchField > fieldset > div:nth-child(1)") searchDiv.insertAdjacentHTML('afterend', ` `) let aliveSection = searchField.querySelector("select[id='alive-section']") let deadSection = searchField.querySelector("select[id='dead-section']") let poster = searchField.querySelector("select[id='poster']") let searchInput = document.querySelector("#searchField > fieldset > div:nth-child(1) > input[type=search]") function setSection(section) { let content = searchInput.value if (section !== "") { section = ' @' + section } if (/ @\S+/.exec(content)) { content = content.replace(/ @\S+/, section) } else content += section searchInput.value = content } function setPoster(uid) { if (uid !== "") { uid = ' @m=' + uid } let content = searchInput.value if (/ @m=\d+/.exec(content)) { content = content.replace(/ @m=\d+/, uid) } else content += uid searchInput.value = content } aliveSection.addEventListener('change', (event) => { if (deadSection.value !== "") { deadSection.value = "" } setSection(event.target.value) }) deadSection.addEventListener('change', (event) => { if (aliveSection.value !== "") { aliveSection.value = "" } setSection(event.target.value) }) poster.addEventListener('change', (event) => { setPoster(event.target.value) }) } let getjson = unsafeWindow.parent.getjsonReal = unsafeWindow.parent.getjson let aidList = [] let irrelevantArchive = [] let allArchive = [] async function joinCallback(url, callback, n) { console.log("joinCallback") if (url[0] === '/') url = 'https:' + url let word = /word=(.*?)(&|$)/.exec(url)[1] let wordList = [] for (let keyword of decodeURIComponent(word).replace(/([^ ])@/, '$1 @').split(' ')) { if (keyword[0] !== '@') { wordList.push(keyword) } } let pn = /p=(\d+)/.exec(url) pn = pn ? pn[1] : '1' if (pn === '1') { aidList = [] irrelevantArchive = [] allArchive = [] } let request = xhrGet(url) let aidSearchUrl = '/api/search?word=' + word + '&page=' + pn let aid_request = xhrGet(aidSearchUrl) let searchResult = JSON.parse(await request) let archive = [] searchResult['data']['items']['archive'].forEach(function (item) { if (item.goto === 'av') { if (aidList.indexOf(item.param) === -1) { aidList.push(item.param) let isRelevant = false for (let keyword of wordList) { for (let key of ['title', 'desc']) { if (item[key].indexOf(keyword) !== -1) { isRelevant = true } } } if (isRelevant) { archive.push(item) } else { irrelevantArchive.push(item) } allArchive.push(item) } } else { archive.push(item) } }) try { let aidSearchResult = JSON.parse((await aid_request))['result'] aidSearchResult.forEach(function (video) { if (aidList.indexOf(video.aid) === -1) { let item = { author: video.author, cover: video.pic, created: new Date(video.created.replace(/-/g, '/')).getTime() / 1000, review: video.review, desc: video.description, goto: "av", param: video.aid, play: video.play, title: video.title, } let isRelevant = false for (let keyword of wordList) { for (let key of ['title', 'desc']) { if (item[key].toLowerCase().indexOf(keyword.toLowerCase()) !== -1) { isRelevant = true } } } if (isRelevant) { archive.push(item) } else { irrelevantArchive.push(item) } allArchive.push(item) } }) } catch (e) { console.log(e) } if (archive.length === 0) { archive = irrelevantArchive irrelevantArchive = [] } searchResult['data']['items']['archive'] = archive callback(searchResult, n) return } unsafeWindow.getjson = unsafeWindow.parent.getjson = function (url, callback, n) { console.log("getjson", arguments) if (url.indexOf("search_api") !== -1 && url.indexOf("source=biliplus") !== -1) { try { return joinCallback(url, callback, n) } catch (e) { console.log(e) return getjson(url, callback, n) } } else return getjson(url, callback, n) }; broadcastChannel.addEventListener('message', function (event) { console.log(event.data) if (event.data.type === 'aidComplete') { let elem = document.querySelector('[id="av' + event.data.aid + '"]') if (elem) elem.textContent = '下载完成' } if (event.data.type === 'aidDownloaded') { let elem = document.querySelector('[id="av' + event.data.aid + '"]') if (elem) elem.textContent = '已下载' } if (event.data.type === 'aidStart') { let elem = document.querySelector('[id="av' + event.data.aid + '"]') if (elem) elem.textContent = '开始下载' } if (event.data.type === 'cidComplete') { let elem = document.querySelector('[id="av' + event.data.aid + '"]') if (elem) elem.textContent = event.data.progress + "%" } }) function updatePointer(pointerElem) { if (pointerElem.querySelector("#videoDetail")) { return } let aidElem = pointerElem .querySelector('div[class="video-card-desc"]') if (!aidElem) { return } let link = pointerElem.getAttribute("data-link") if (link) { console.log(link) pointerElem.removeAttribute("data-link") pointerElem.addEventListener('click', async function (event) { console.log(event) event.preventDefault() if (event.target.className !== 'download') { unsafeWindow.openOrigin(link) } }) } let aid = parseInt(aidElem.textContent.slice(2)) if (!aidElem.querySelector('[class="download"]')) { let downloadButton = createElement('