Greasy Fork

Greasy Fork is available in English.

B站“稍后再看”功能增强

B站“稍后再看”功能增强,目前功能包括顶栏和播放页中相关UI、重定向至常规播放页、防误删记录等,支持功能设置

当前为 2020-07-13 提交的版本,查看 最新版本

// ==UserScript==
// @id             BilibiliWatchlaterPlus@Laster2800
// @name           B站“稍后再看”功能增强
// @version        2.0
// @namespace      laster2800
// @author         Laster2800
// @description    B站“稍后再看”功能增强,目前功能包括顶栏和播放页中相关UI、重定向至常规播放页、防误删记录等,支持功能设置
// @include        *://www.bilibili.com/*
// @include        *://message.bilibili.com/*
// @include        *://search.bilibili.com/*
// @include        *://space.bilibili.com/*
// @include        *://t.bilibili.com/
// @include        *://t.bilibili.com/?spm_id_from=*
// @include        *://t.bilibili.com/?tab=*
// @exclude        *://message.bilibili.com/pages/*
// @grant          GM_addStyle
// @grant          GM_xmlhttpRequest
// @grant          GM_registerMenuCommand
// @grant          GM_setValue
// @grant          GM_getValue
// @connect        api.bilibili.com
// @run-at         document-start
// ==/UserScript==

(function() {
    // 用户配置读取
    var init = GM_getValue('gm395456')
    var configUpdate = 20200713
    var headerButton = true
    var videoButton = true
    var redirect = false
    var openInNew = false
    var watchlaterHistory = false
    if (init >= configUpdate) {
        headerButton = GM_getValue('gm395456_headerButton')
        videoButton = GM_getValue('gm395456_videoButton')
        redirect = GM_getValue('gm395456_redirect')
        openInNew = GM_getValue('gm395456_openInNew')
        watchlaterHistory = GM_getValue('gm395456_watchlaterHistory')
    } else {
        init = false
        GM_setValue('gm395456_headerButton', headerButton)
        GM_setValue('gm395456_videoButton', videoButton)
        GM_setValue('gm395456_redirect', redirect)
        GM_setValue('gm395456_openInNew', openInNew)
        GM_setValue('gm395456_watchlaterHistory', watchlaterHistory)
    }

    // 重定向,document-start 就执行,尽可能快地将原页面掩盖过去
    if (redirect && /bilibili.com\/medialist\/play\/watchlater\//.test(location.href)) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp`,
            onload: function(response) {
                if (response && response.responseText) {
                    try {
                        var part = parseInt(location.href.match(/(?<=\/watchlater\/p)\d+(?=\/?)/)[0])
                        var json = JSON.parse(response.responseText)
                        var watchList = json.data.list
                        location.replace('https://www.bilibili.com/video/' + watchList[part - 1].bvid)
                    } catch(e) {
                        console.error('重定向错误,请联系脚本作者( http://greasyfork.icu/zh-CN/scripts/383441/feedback ):\n' + e)
                    }
                }
            }
        })
    }

    // 脚本的其他部分推迟至 DOMContentLoaded 执行
    document.addEventListener('DOMContentLoaded', () => {
        GM_addStyle(`
#gm395456 .gm_setting {
    font-size: 12px;
}
#gm395456 .gm_setting .gm_settingPage {
	position: fixed;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	background-color: #ffffff;
	border: 1px solid #ccd0d7;
	border-radius: 10px;
	z-index: 65535;
}
#gm395456 .gm_setting .gm_items {
	margin: 0 2em;
	font-size: 1.1em;
}
#gm395456 .gm_setting .gm_item {
	cursor: pointer;
	display: block;
	padding: 0.3em;

}
#gm395456 .gm_setting .gm_item:hover {
	color: #0075FF;
}
#gm395456 .gm_setting .gm_item input[type=checkbox] {
	vertical-align: middle;
	margin: 3px 0 0 10px;
    float: right;
}
#gm395456 .gm_setting .gm_bottom {
	margin: 1.2em 2em;
	text-align: center;
}
#gm395456 .gm_setting .gm_bottom button {
	font-size: 1em;
	padding: 0.2em 0.5em;
	margin: 0 0.4em;
	cursor: pointer;
}

#gm395456 .gm_history .gm_historyPage {
	position: fixed;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	background-color: #ffffff;
	border: 1px solid #ccd0d7;
	border-radius: 10px;
	z-index: 65535;
    height: 75%;
    width: 60%;
    overflow-y: auto;
}
#gm395456 .gm_history .gm_historyPage::-webkit-scrollbar {
    display: none;
}
#gm395456 .gm_history .gm_comment {
    margin: 0 2em;
    text-align: center;
    color: gray;
}
#gm395456 .gm_history .gm_content {
    margin: 1.5em;
    font-size: 1.2em;
    text-align: center;
}

#gm395456 .gm_title{
	font-size: 1.6em;
	margin: 0.8em;
	text-align: center;
}
#gm395456 .gm_shadow {
	background: #000000b0;
	position: fixed;
	top: 0%;
	left: 0%;
	z-index: 10000;
	width: 100%;
	height: 100%;
}
        `)

        var el_gm395456 = document.body.appendChild(document.createElement('div'))
        el_gm395456.id = 'gm395456'
        var el_setting = null
        var el_history = null

        // 用户配置设置
        GM_registerMenuCommand('设置', () => openUserSetting(el_gm395456, el_setting))
        if (!init) {
            openUserSetting(el_gm395456, el_setting, true)
        }

        // 正式开始处理
        if (headerButton) {
            // 顶栏中加入“稍后再看”入口
            executeAfterElementLoad({
                selector: '.user-con.signin',
                callback: addHeaderWatchlaterButton,
            })
        }
        if (videoButton && /bilibili.com\/video(|\/.*)$/.test(location.href)) {
            // 常规播放页加入快速切换“稍后再看”状态的按钮
            executeAfterConditionPass({
                condition: () => {
                    // 必须在确定 Vue 加载完成后再修改 DOM 结构,否则会导致 Vue 加载出错造成页面错误
                    var app = document.querySelector('#app')
                    var vueLoad = app && app.__vue__
                    if (!vueLoad) {
                        return false
                    }
                    var atr = document.querySelector('#arc_toolbar_report')
                    var original = atr && atr.querySelector('.van-watchlater')
                    if (original && original.__vue__) {
                        return [atr, original]
                    } else {
                        return false
                    }
                },
                callback: addVideoWatchlaterButton,
            })
        } else if (/bilibili.com\/watchlater\/.*\/list/.test(location.href)) {
            if (openInNew) {
                switchOpenInNew() // 新标签页打开
            }
            if (watchlaterHistory) {
                var wHistory = []
                // 将此时的稍后再看列表保存起来
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp`,
                    onload: function(response) {
                        if (response && response.responseText) {
                            try {
                                var json = JSON.parse(response.responseText)
                                var watchList = json.data.list
                                for (var e of watchList) {
                                    wHistory.push(`${e.title}<br>AV: ${e.aid}, BV: ${e.bvid}`)
                                }
                            } catch(e) {
                                console.error('保存稍后再看列表错误,请联系脚本作者( http://greasyfork.icu/zh-CN/scripts/383441/feedback ):\n' + e)
                            }
                        }
                    }
                })
                // 防误删记录
                GM_registerMenuCommand('显示防误删记录', () => openWatchlaterHistory(el_gm395456, el_history, wHistory))
            }
        }

        // 打开用户设置
        function openUserSetting(parent, el, initial) {
            if (el) {
                el.style.display = ''
            } else {
                el = parent.appendChild(document.createElement('div'))
                el.className = 'gm_setting'
                el.innerHTML = `
<div class="gm_settingPage">
    <div class="gm_title">B站“稍后再看”功能增强</div>
    <div class="gm_items">
        <label class="gm_item" title="在顶栏“动态”和“收藏”之间加入“稍后再看”按钮,支持弹出式列表菜单">
            <span>【所有页面】在顶栏中加入“稍后再看”入口</span><input id="gm_headerButton" type="checkbox"></label>
        <label class="gm_item" title="在常规播放页面中加入能将视频快速切换添加或移除出“稍后再看”列表的按钮">
            <span>【播放页面】加入快速切换“稍后再看”状态的按钮</span><input id="gm_videoButton" type="checkbox"></label>
        <label class="gm_item" title="是否自动从【www.bilibili.com/medialist/play/watchlater/p*】页面切换至【www.bilibili.com/video/BV*】页面播放">
            <span>【播放页面】是否重定向至常规播放页面</span><input id="gm_redirect" type="checkbox"></label>
        <label class="gm_item" title="在【www.bilibili.com/watchlater/#/list】页面点击时,是否在新标签页打开视频">
            <span>【列表页面】是否在新标签页中打开视频</span><input id="gm_openInNew" type="checkbox"></label>
        <label class="gm_item" title="每次打开【www.bilibili.com/watchlater/#/list】页面时将稍后再看列表数据记录下来,方便误删后查找">
            <span>【列表页面】是否开启防误删记录功能</span><input id="gm_watchlaterHistory" type="checkbox"></label>
    </div>
    <div class="gm_bottom">
        <button id="gm_save">保存</button><button id="gm_cancel">取消</button>
    </div>
</div>
<div class="gm_shadow"></div>
`
                var el_headerButton = el.querySelector('#gm_headerButton')
                el_headerButton.checked = headerButton
                var el_videoButton = el.querySelector('#gm_videoButton')
                el_videoButton.checked = videoButton
                var el_redirect = el.querySelector('#gm_redirect')
                el_redirect.checked = redirect
                var el_openInNew = el.querySelector('#gm_openInNew')
                el_openInNew.checked = openInNew
                var el_watchlaterHistory = el.querySelector('#gm_watchlaterHistory')
                el_watchlaterHistory.checked = watchlaterHistory

                var save = el.querySelector('#gm_save')
                save.onclick = () => {
                    headerButton = el_headerButton.checked
                    GM_setValue('gm395456_headerButton', headerButton)
                    videoButton = el_videoButton.checked
                    GM_setValue('gm395456_videoButton', videoButton)
                    redirect = el_redirect.checked
                    GM_setValue('gm395456_redirect', redirect)
                    openInNew = el_openInNew.checked
                    GM_setValue('gm395456_openInNew', openInNew)
                    switchOpenInNew()
                    watchlaterHistory = el_watchlaterHistory.checked
                    GM_setValue('gm395456_watchlaterHistory', watchlaterHistory)
                    el.style.display = 'none'
                    if (initial) {
                        init = configUpdate
                        GM_setValue('gm395456', init)
                    }
                }
                var cancel = el.querySelector('#gm_cancel')
                cancel.disabled = initial
                cancel.onclick = () => {
                    el.style.display = 'none'
                    el_headerButton.checked = headerButton
                    el_videoButton.checked = videoButton
                    el_redirect.checked = redirect
                    el_openInNew.checked = openInNew
                    el_watchlaterHistory.checked = watchlaterHistory
                }
            }
        }

        // 打开防误删记录
        function openWatchlaterHistory(parent, el, wHistory) {
            if (el) {
                el.style.display = ''
            } else {
                el = parent.appendChild(document.createElement('div'))
                el.className = 'gm_history'
                el.innerHTML = `
<div class="gm_historyPage">
    <div class="gm_title">稍后再看防误删记录</div>
    <div class="gm_comment">仅保留当前页面打开时的稍后再看列表数据,可向下滚动,点击对话框以外的位置退出</div>
    <div class="gm_content">
        ${wHistory.join('<br><br>')}
    </div>
</div>
<div class="gm_shadow"></div>
`
                var el_shadow = el.querySelector('.gm_shadow')
                el_shadow.onclick = () => { el.style.display = 'none' }
            }
        }

        function addHeaderWatchlaterButton(header) {
            if (header) {
                var collect = header.children[4]
                var watchlater = header.children[6].cloneNode(true)
                var link = watchlater.firstChild
                link.href = 'https://www.bilibili.com/watchlater/#/list'
                var text = link.firstChild
                text.innerText = '稍后再看'
                header.insertBefore(watchlater, collect)

                // 鼠标移动到“稍后再看”按钮上时,以 Tooltip 形式显示“稍后再看”列表
                var watchlaterPanelSelector = '[role=tooltip][aria-hidden=false] .tabs-panel [title=稍后再看]'
                var dispVue = collect.firstChild.__vue__
                watchlater.onmouseover = () => {
                    // 确保原列表完全消失后再显示,避免从“收藏”移动到“稍后再看”时列表反而消失的问题
                    executeAfterConditionPass({
                        condition: () => !document.querySelector(watchlaterPanelSelector),
                        callback: () => {
                            dispVue.showPopper = true
                            executeAfterElementLoad({
                                selector: watchlaterPanelSelector,
                                callback: watchlaterPanel => watchlaterPanel.parentNode.click(),
                                interval: 50,
                                timeout: 1500,
                            })
                        },
                        interval: 10,
                        timeout: 500,
                    })
                }
                // 鼠标从“稍后再看”离开时关闭列表,但移动到“收藏”上面时不关闭
                collect.onmouseover = () => { collect.mouseOver = true }
                collect.onmouseleave = () => { collect.mouseOver = false }
                watchlater.onmouseleave = () => {
                    // 要留出足够空间让 collect.mouseOver 变化
                    // 但有时候还是会闪,毕竟常规方式估计是无法阻止鼠标移动到“收藏”上时的 Vue 事件
                    setTimeout(() => {
                        if (!collect.mouseOver) {
                            dispVue.showPopper = false
                        }
                    }, 100)
                }
            }
        }

        function addVideoWatchlaterButton([atr, original]) {
            var oVue = original.__vue__
            var btn = document.createElement('label')
            var cb = document.createElement('input')
            cb.type = 'checkbox'
            cb.style.verticalAlign = 'middle'
            cb.style.margin = '0 2px 2px 0'
            btn.appendChild(cb)
            var text = document.createElement('span')
            text.innerText = '稍后再看'
            btn.className = 'appeal-text'
            cb.onclick = () => { // 不要附加到 btn 上,否则点击时会执行两次
                oVue.handler()
                var checked = !oVue.added
                // 检测操作是否生效,失败时弹出提示
                executeAfterConditionPass({
                    condition: () => checked === oVue.added,
                    callback: () => { cb.checked = checked },
                    interval: 50,
                    timeout: 500,
                    onTimeout: () => {
                        cb.checked = oVue.added
                        alert(checked ? '添加至稍后再看失败' : '从稍后再看移除失败')
                    },
                })
            }
            btn.appendChild(text)
            atr.appendChild(btn)
            original.parentNode.style.display = 'none'

            // oVue.added 第一次取到的值总是 false,从页面无法获取到该视频是否已经在稍后再看列表中,需要使用API查询
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp`,
                onload: function(response) {
                    if (response && response.responseText) {
                        try {
                            var json = JSON.parse(response.responseText)
                            var watchList = json.data.list
                            var av = oVue.aid
                            for (var e of watchList) {
                                if (av == e.aid) {
                                    oVue.added = true
                                    cb.checked = true
                                    break
                                }
                            }
                        } catch(e) {
                            console.error(e)
                        }
                    }
                }
            })
        }

        // 切换新标签页打开
        function switchOpenInNew() {
            if (/bilibili.com\/watchlater\/.*\/list/.test(location.href)) {
                var base = null
                if (openInNew) {
                    base = document.head.appendChild(document.createElement('base'))
                    base.id = 'gm_base'
                    base.target = '_blank'
                } else {
                    base = document.head.querySelector('base#gm_base')
                    base && base.remove()
                }
            }
        }

        /**
		 * 在条件满足后执行操作
		 *
		 * 当条件满足后,如果不存在终止条件,那么直接执行 callback(result)。
		 *
		 * 当条件满足后,如果存在终止条件,且 stopTimeout 大于 0,则还会在接下来的 stopTimeout 时间内判断是否满足终止条件,称为终止条件的二次判断。
		 * 如果在此期间,终止条件通过,则表示依然不满足条件,故执行 stopCallback() 而非 callback(result)。
		 * 如果在此期间,终止条件一直失败,则顺利通过检测,执行 callback(result)。
		 *
		 * @param {Object} [options={}] 选项
		 * @param {Function} [options.condition] 条件,当 condition() 返回的 result 为真值时满足条件
		 * @param {Function} [options.callback] 当满足条件时执行 callback(result)
		 * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
		 * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
		 * @param {Function} [options.onTimeout] 检测超时时执行 onTimeout()
		 * @param {Function} [options.stopCondition] 终止条件,当 stopCondition() 返回的 stopResult 为真值时终止检测
		 * @param {Function} [options.stopCallback] 终止条件达成时执行 stopCallback()(包括终止条件的二次判断达成)
		 * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
		 * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
		 */
        function executeAfterConditionPass(options) {
            var defaultOptions = {
                condition: () => true,
                callback: result => console.log(result),
                interval: 100,
                timeout: 5000,
                onTimeout: null,
                stopCondition: null,
                stopCallback: null,
                stopInterval: 50,
                stopTimeout: 0,
            }
            var o = { ...defaultOptions, ...options }
            if (!o.callback instanceof Function) {
                return
            }

            var cnt = 0
            var maxCnt = o.timeout / o.interval
            var tid = setInterval(() => {
                var result = o.condition()
                var stopResult = o.stopCondition && o.stopCondition()
                if (stopResult) {
                    clearInterval(tid)
                    o.stopCallback instanceof Function && o.stopCallback()
                } else if (++cnt > maxCnt) {
                    clearInterval(tid)
                    o.onTimeout instanceof Function && o.onTimeout()
                } else if (result) {
                    clearInterval(tid)
                    if (o.stopCondition && o.stopTimeout > 0) {
                        executeAfterConditionPass({
                            condition: o.stopCondition,
                            callback: o.stopCallback,
                            interval: o.stopInterval,
                            timeout: o.stopTimeout,
                            onTimeout: () => o.callback(result)
                        })
                    } else {
                        o.callback(result)
                    }
                }
            }, o.interval)
            }

        /**
		 * 在元素加载完成后执行操作
		 *
		 * 当元素加载成功后,如果没有设置终止元素选择器,那么直接执行 callback(element)。
		 *
		 * 当元素加载成功后,如果没有设置终止元素选择器,且 stopTimeout 大于 0,则还会在接下来的 stopTimeout 时间内判断终止元素是否加载成功,称为终止元素的二次加载。
		 * 如果在此期间,终止元素加载成功,则表示依然不满足条件,故执行 stopCallback() 而非 callback(element)。
		 * 如果在此期间,终止元素加载失败,则顺利通过检测,执行 callback(element)。
		 *
		 * @param {Object} [options={}] 选项
		 * @param {Function} [options.selector] 该选择器指定要等待加载的元素 element
		 * @param {Function} [options.callback] 当 element 加载成功时执行 callback(element)
		 * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
		 * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
		 * @param {Function} [options.onTimeout] 检测超时时执行 onTimeout()
		 * @param {Function} [options.stopCondition] 该选择器指定终止元素 stopElement,若该元素加载成功则终止检测
		 * @param {Function} [options.stopCallback] 终止元素加载成功后执行 stopCallback()(包括终止元素的二次加载)
		 * @param {number} [options.stopInterval=50] 终止元素二次加载期间的检测时间间隔(单位:ms)
		 * @param {number} [options.stopTimeout=0] 终止元素二次加载期间的检测超时时间(单位:ms)
		 */
        function executeAfterElementLoad(options) {
            var defaultOptions = {
                selector: '',
                callback: el => console.log(el),
                interval: 100,
                timeout: 5000,
                onTimeout: null,
                stopSelector: null,
                stopCallback: null,
                stopInterval: 50,
                stopTimeout: 0,
            }
            var o = { ...defaultOptions, ...options }
            executeAfterConditionPass({
                ...o,
                condition: () => document.querySelector(o.selector),
                stopCondition: o.stopSelector && (() => document.querySelector(o.stopSelector)),
            })
        }
    })
})()