Greasy Fork

Greasy Fork is available in English.

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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