Greasy Fork

Greasy Fork is available in English.

解除B站区域限制

通过替换获取视频地址接口的方式, 实现解除B站区域限制; 只对HTML5播放器生效; 只支持bangumi.bilibili.com域名下的番剧视频;

当前为 2017-04-30 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         解除B站区域限制
// @namespace    http://tampermonkey.net/
// @version      5.0.7
// @description  通过替换获取视频地址接口的方式, 实现解除B站区域限制; 只对HTML5播放器生效; 只支持bangumi.bilibili.com域名下的番剧视频;
// @author       ipcjs
// @require      https://static.hdslb.com/js/md5.js
// @include      *://www.bilibili.com/video/av*
// @include      *://bangumi.bilibili.com/anime/*
// @include      *://www.bilibili.com/blackboard/html5player.html*
// @run-at       document-start
// @grant        none
// ==/UserScript==

'use strict';
log('[' + GM_info.script.name + '] run on: ' + window.location.href);

var MODE_DEFAULT = 'default'; // 默认模式, 自动判断使用何种模式, 推荐;
var MODE_REPLACE = 'replace'; // 替换模式, 替换有区域限制的视频的接口的返回值; 因为替换的操作是同步的会卡一下界面, 但没有区域限制的视频不会受到影响;
var MODE_REDIRECT = 'redirect'; // 重定向模式, 直接重定向所有番剧视频的接口到代理服务器; 所有番剧视频都通过代理服务器获取视频地址, 如果代理服务器不稳定, 可能加载不出视频;

var settings = getCookies();
var biliplusHost = settings.balh_server; // 优先从cookie中读取服务器地址
var isBlockedVip = settings.balh_blocked_vip; // "我是一位被永久封号的大会员"(by Google翻译)
var mode = settings.balh_mode || (isBlockedVip ? MODE_REDIRECT : MODE_DEFAULT); // 若账号是被永封的大会员, 默认使用重定向模式
if (!biliplusHost) {
    biliplusHost = 'http://biliplus.ipcjsdev.tk'; // 我的反向代理服务器
    // biliplusHost = 'https://www.biliplus.com'; // 支持https的服务器
}

log('Mode:', mode, 'isBlockedVip:', isBlockedVip, 'server:', biliplusHost, 'readyState:', document.readyState);

var api = {
    syncAjax: function (one_api, originUrl) {
        var data;
        $.ajax({
            url: one_api.transUrl(originUrl),
            async: false,
            xhrFields: {withCredentials: true},
            success: function (result) {
                data = JSON.stringify(one_api.processSuccess(result));
                log('==>', data);
                // log('success', arguments, this);
            },
            error: function () {
                log('error', arguments, this);
            }
        });
        return data;
    },
    asyncAjax: function (one_api, originUrl, success, error) {
        $.ajax({
            url: one_api.transUrl(originUrl),
            async: true,
            xhrFields: {withCredentials: true},
            success: function (result) {
                log('==>', result);
                success(one_api.processSuccess(result));
                // log('success', arguments, this);
            },
            error: function (e) {
                log('error', arguments, this);
                error(e);
            }
        });
    },
    _get_view: {
        transUrl: function (url) {
            var id = url.replace(/.*av(\d+).*/, '$1');
            return biliplusHost + '/api/view?id=' + id;
        },
        processSuccess: function (data) {
            var bangumi = data.bangumi;
            if (!bangumi) {
                return null;
            }
            var int = parseInt,
                old_ep_id = location.pathname.replace(/.*av\d+.(?:index_(\d+).*)?/, '$1'),
                ep_index = (int(old_ep_id) -1) ||
                    (int(data.season_index) - 1) ||
                    (int(data.description.replace(/\D(\d+)/, '$1')) - 1);

            ep_index = (isNaN(ep_index) || ep_index < 1) ? 0 : ep_index;

            return {
                ep_index: ep_index,
                season_id: bangumi.season_id
            };
        }
    },
    _get_source: {
        transUrl: function (url) {
            return biliplusHost + '/api/bangumi?season=' + window.season_id;
        },
        processSuccess: function (data) {
            var found = null;
            for (var i = 0; i < data.result.episodes.length; i++) {
                if (data.result.episodes[i].episode_id == window.episode_id) {
                    found = data.result.episodes[i];
                }
            }
            var returnVal = found !== null ? {
                "code": 0,
                "message": "success",
                "result": {
                    "aid": found.av_id,
                    "cid": found.danmaku,
                    "episode_status": isBlockedVip ? 2 : found.episode_status,
                    "payment": {"price": "9876547210.33"},
                    "player": "vupload",
                    "pre_ad": 0,
                    "season_status": isBlockedVip ? 2 : data.result.season_status
                }
            } : {
                code: -404,
                message: '不存在该剧集'
            };
            return returnVal;
        }
    },
    _playurl: {
        transUrl: function (url) {
            return biliplusHost + '/BPplayurl.php?' + url.split('?')[1].replace(/(cid=\d+)/, '$1|' + (url.match(/module=(\w+)/) || ['', 'bangumi'])[1]);
        },
        processSuccess: function (data) {
            if (data.code === -403) {
                // window.alert('当前使用的服务器(' + biliplusHost + ')依然有区域限制');
                showNotification(Date.now(), GM_info.script.name, '突破黑洞失败,我们未能穿透敌人的盔甲\n当前代理服务器(' + biliplusHost + ')依然有区域限制Σ(  ̄□ ̄||)', '//bangumi.bilibili.com/favicon.ico', 3e3);
            } else if (data.code) {
                console.error(data);
                showNotification(Date.now(), GM_info.script.name, '突破黑洞失败\n' + JSON.stringify(data), '//bangumi.bilibili.com/favicon.ico', 3e3);
            } else if (isAreaLimitForPlayUrl(data)) {
                console.error('>>aera limit');
                showNotification(Date.now(), GM_info.script.name, '突破黑洞失败,需要登录\n点此进行登录', '//bangumi.bilibili.com/favicon.ico', 3e3, showLogin);
                // if (window.confirm('试图获取视频地址失败, 请登录biliplus' +
                //         '\n注意: 只支持"使用bilibili账户密码进行登录"'
                //     )) {
                //     window.top.location = biliplusHost + '/login';
                // }
            } else {
                // showNotification(Date.now(), GM_info.script.name, '已突破黑洞,开始加载视频', '//bangumi.bilibili.com/favicon.ico', 2e3);
            }
            return data;
        }
    }
};

if (!window.jQuery) { // 若还未加载jQuery, 则监听
    var jQuery;
    Object.defineProperty(window, 'jQuery', {
        configurable: true, enumerable: true, set: function (v) {
            jQuery = v;
            injectDataFilter();// 设置jQuery后, 立即注入
        }, get: function () {
            return jQuery;
        }
    });
} else {
    injectDataFilter();
}

documentReady(function () {
    if (window.location.hostname === 'bangumi.bilibili.com') {
        checkLoginState();
        checkHtml5();
    } else if(location.href.includes('www.bilibili.com/video/av')) {
        tryBangumiRedirect();
    }
});

// 暴露接口
window.bangumi_aera_limit_hack = {
    setCookie: function (key, value, options) {
        return setCookie(key === 'bangumi_aera_limit_hack_server' ? 'balh_server' : key, value, options);
    },
    getCookie: function (key) {
        return getCookie(key === 'bangumi_aera_limit_hack_server' ? 'balh_server' : key);
    },
    login: showLogin,
    _clear_local_value: function () {
        delete localStorage.balh_notFirst;
        delete localStorage.balh_login;
        delete localStorage.balh_mainLogin;
        delete localStorage.oauthTime;
        delete localStorage.balh_h5_not_first;
    }
};

////////////////接下来全是函数/////////////////

function injectDataFilter() {
    window.jQuery.ajaxSetup({
        dataFilter: jqueryDataFilter
    });
    replaceAjax();
}

function jqueryDataFilter(data, type) {
    var json, group;
    // log(arguments, this);
    if (this.url.startsWith(window.location.protocol + '//bangumi.bilibili.com/web_api/season_area')) {
        // 番剧页面是否要隐藏番剧列表 API
        log(data);
        json = JSON.parse(data);
        // 限制区域时的data为:
        // {"code":0,"message":"success","result":{"play":0}}
        if (json.code === 0 && json.result && json.result.play === 0) {
            mode === MODE_DEFAULT && setAreaLimitSeason(true);
            json.result.play = 1; // 改成1就能够显示
            data = JSON.stringify(json);
            log('==>', data);
            showNotification(Date.now(), GM_info.script.name, '检测到区域限制番剧,准备启动黑洞突破程序\n\n刷下存在感(°∀°)ノ', '//bangumi.bilibili.com/favicon.ico', 2e3);
        } else {
            mode === MODE_DEFAULT && setAreaLimitSeason(false);
        }
    }
    return data;
}

/*
 {"code":0,"message":"success","result":{"aid":9854952,"cid":16292628,"episode_status":2,"payment":{"price":"0"},"player":"vupload","pre_ad":0,"season_status":2}}
 */
function replaceAjax() {
    var originalAjax = $.ajax;
    $.ajax = function (arg0, arg1) {
        // log(arguments);
        var param;
        if (arg1 === undefined) {
            param = arg0;
        } else {
            arg0 && (arg1.url = arg0);
            param = arg1;
        }
        var oriSuccess = param.success;
        var one_api;
        if (param.url.match('/web_api/get_source')) {
            one_api = api._get_source;
            if (mode === MODE_REDIRECT || (mode === MODE_DEFAULT && isAreaLimitSeason())) { // 对应redirect模式
                param.url = one_api.transUrl(param.url);
                param.type = 'GET';
                delete param.data;
                param.success = function (data) {
                    var returnVal = one_api.processSuccess(data);
                    log('Redirected request: get_source', returnVal);
                    oriSuccess(returnVal);
                };
            } else { // 对应replace模式
                param.success = function (json) {
                    log(json);
                    if (json.code === -40301 // 区域限制
                        || json.result.pay_user && isBlockedVip) { // 需要付费的视频, 此时B站返回的cid是错了, 故需要使用biliplus的接口
                        api.asyncAjax(one_api, param.url, oriSuccess, function (e) {
                            oriSuccess(json); // 新的请求报错, 也应该返回原来的数据
                        });
                        mode === MODE_DEFAULT && setAreaLimitSeason(true); // 只要默认模式才要跟踪是否有区域限制
                    } else {
                        mode === MODE_DEFAULT && setAreaLimitSeason(false);
                        if (isBlockedVip && json.code === 0 && json.result.pre_ad) {
                            json.result.pre_ad = 0; // 去除前置广告
                        }
                        oriSuccess(json); // 保证一定调用了原来的success
                    }
                };
            }
        } else if (param.url.match('/player/web_api/playurl')) {
            one_api = api._playurl;
            if (mode === MODE_REDIRECT || (mode === MODE_DEFAULT && isAreaLimitSeason())) {
                param.url = one_api.transUrl(param.url);
                param.success = function (data) {
                    oriSuccess(one_api.processSuccess(data));
                };
                log('Redirected request: bangumi playurl -> ', param.url);
            } else {
                param.success = function (json) {
                    // 获取视频地址 API
                    log(json);
                    if (isBlockedVip || isAreaLimitForPlayUrl(json)) {
                        api.asyncAjax(one_api, param.url, oriSuccess, function (e) {
                            oriSuccess(json);
                        });
                        mode === MODE_DEFAULT && setAreaLimitSeason(true);
                    } else {
                        mode === MODE_DEFAULT && setAreaLimitSeason(false);
                        oriSuccess(json);
                    }
                }
            }
        }
        // default
        return originalAjax.apply(this, [param]);
    }
}

function isAreaLimitSeason() {
    return getCookie('balh_season_' + getSeasonId());
}
function setAreaLimitSeason(limit) {
    var season_id = getSeasonId();
    setCookie('balh_season_' + season_id, limit ? '1' : undefined, ''); // 第三个参数为'', 表示时Session类型的cookie
    log('setAreaLimitSeason', season_id, limit);
}

function getSeasonId() {
    try {
        return window.season_id || window.top.season_id;
    } catch (e) {
        console.error(e);
        return (window.top.location.pathname.match(/\/anime\/(\d+)/) || ['', '000'])[1];
    }
}

function isAreaLimitForPlayUrl(json) {
    return json.durl && json.durl.length === 1 && json.durl[0].length === 15126 && json.durl[0].size === 124627;
}

function getParam(url, key) {
    return (url.match(new RegExp('[?|&]' + key + '=(\\w+)')) || ['', ''])[1];
}

function getCookies() {
    var map = document.cookie.split('; ').reduce(function (obj, item) {
        var entry = item.split('=');
        obj[entry[0]] = entry[1];
        return obj;
    }, {});
    return map;
}

function getCookie(key) {
    return getCookies()[key];
}
// document.cookie=`bangumi_aera_limit_hack_server=https://www.biliplus.com; domain=.bilibili.com; path=/; expires=${new Date("2020-01-01").toUTCString()}`;

/**
 * @key     key
 * @value   为undefined时, 表示删除cookie
 * @options 为undefined时, 表示过期时间为3年
 *          为''时, 表示Session cookie
 *          为数字时, 表示指定过期时间
 *          为{}时, 表示指定所有的属性
 * */
function setCookie(key, value, options) {
    if (typeof options !== 'object') {
        options = {
            domain: '.bilibili.com',
            path: '/',
            'max-age': value === undefined ? 0 : (options === undefined ? 94608000 : options)
        };
    }
    var c = Object.keys(options).reduce(function (str, key) {
        return str + '; ' + key + '=' + options[key];
    }, key + '=' + value);
    document.cookie = c;
    return c;
}

function showLogin() {
    if (!document.getElementById('balh-style-login')) {
        var style = document.createElement('style');
        style.id = 'balh-style-login';
        document.head.appendChild(style).innerHTML = '@keyframes pop-iframe-in{0%{opacity:0;transform:scale(.7);}100%{opacity:1;transform:scale(1)}}@keyframes pop-iframe-out{0%{opacity:1;transform:scale(1);}100%{opacity:0;transform:scale(.7)}}.GMBiliPlusCloseBox{position:absolute;top:5%;right:8%;font-size:40px;color:#FFF}';
    }

    var loginUrl = biliplusHost + '/login',
        iframeSrc = 'https://passport.bilibili.com/login?appkey=27eb53fc9058f8c3&api=' + encodeURIComponent(loginUrl) + '&sign=' + hex_md5('api=' + loginUrl + 'c2ed53a74eeefe3cf99fbd01d8c9c375');

    var div = document.createElement('div');
    div.id = 'GMBiliPlusLoginContainer';
    div.innerHTML = '<div style="position:fixed;top:0;left:0;z-index:10000;width:100%;height:100%;background:rgba(0,0,0,.5);animation-fill-mode:forwards;animation-name:pop-iframe-in;animation-duration:.5s;cursor:pointer"><iframe src="' + iframeSrc + '" style="background:#e4e7ee;position:absolute;top:10%;left:10%;width:80%;height:80%"></iframe><div class="GMBiliPlusCloseBox">×</div></div>';
    div.firstChild.addEventListener('click', function (e) {
        if (e.target === this || e.target.className === 'GMBiliPlusCloseBox') {
            if (!confirm('确认关闭?')) {
                return false;
            }
            div.firstChild.style.animationName = 'pop-iframe-out';
            setTimeout(function () {
                div.remove();
                window.location.reload();
            }, 5e2);
        }
    });
    document.body.appendChild(div);
    delete localStorage.balh_login;
}

// 逻辑有点乱, 当前在如下情况才会弹一次登录提示框:
// 1. 第一次使用
// 2. 主站+服务器都退出登录后, 再重新登录主站
function checkLoginState() {
    if (getCookie("DedeUserID") === undefined) {
        //未登录主站,强制指定值
        localStorage.balh_notFirst = 1;
        localStorage.balh_login = 0;
        localStorage.balh_mainLogin = 0
    } else if (localStorage.balh_mainLogin !== undefined) {
        //主站未登录变为登录,重置显示弹窗
        delete localStorage.balh_notFirst;
        delete localStorage.balh_login;
        delete localStorage.balh_mainLogin;
        delete localStorage.oauthTime;
    }
    if (!localStorage.balh_notFirst) {
        //第一次打开,确认是否已登陆;未登录显示确认弹窗
        localStorage.balh_notFirst = 1;
        checkExpiretime(function () {
            if (localStorage.oauthTime === undefined) {
                localStorage.balh_login = 0;
                if (confirm('看起来你是第一次使用' + GM_info.script.name + '\n要不要考虑进行一下授权?\n\n授权后可以观看区域限定番剧的1080P(如果你是大会员或承包过的话)\n\n你可以随时通过执行 bangumi_aera_limit_hack.login() 来打开授权页面')) {
                    showLogin();
                }
            } else {
                localStorage.balh_login = 1;
            }
        });
    } else if (localStorage.balh_login === undefined) {
        //非第一次打开,登录状态被重置,重新检测
        checkExpiretime(function () {
            localStorage.balh_login = (localStorage.oauthTime === undefined) ? 0 : 1
        });
    } else if (localStorage.balh_login == 1 && Date.now() - parseInt(localStorage.oauthTime) > 24 * 60 * 60 * 1000) {
        //已登录,每天为周期检测key有效期,过期前五天会自动续期
        checkExpiretime();
    }

    function checkExpiretime(loadCallback) {
        var script = document.createElement('script');
        script.src = biliplusHost + '/login?act=expiretime';
        loadCallback && script.addEventListener('load', loadCallback);
        document.head.appendChild(script);
    }
}

function checkHtml5() {
    if (!localStorage.balh_h5_not_first && localStorage.defaulth5 == 0 && window.GrayManager) {
        new MutationObserver(function (mutations, observer) {
            observer.disconnect();
            localStorage.balh_h5_not_first = 'yes';
            if (window.confirm(GM_info.script.name + '只在HTML5播放器下有效,是否切换到HTML5?')) {
                window.GrayManager.clickMenu('change_h5');// change_flash, change_h5
            }
        }).observe(document.querySelector('.player-content'), {
            childList: true, // 监听child的增减
            attributes: false, // 监听属性的变化
        });
    }
}

// document载入完成后回调, 相当于$(cb);
function documentReady(cb) {
    if (document.readyState !== 'loading') {
        return setTimeout(cb, 1);
    }
    var cbWrapper = function () {
        document.removeEventListener('DOMContentLoaded', cbWrapper);
        cb();
    };
    document.addEventListener('DOMContentLoaded', cbWrapper);
}

function log() {
    console.log.apply(console, arguments);
}

/**
 * 通知模块
 * 剽窃自 YAWF 用户脚本
 * 硬广:https://tiansh.github.io/yawf/
 */
var notify = (function () {
    var avaliable = {};
    var shown = [];
    var use = {
        'hasPermission': function () {
            return null;
        },
        'requestPermission': function (callback) {
            return null;
        },
        'hideNotification': function (notify) {
            return null;
        },
        'showNotification': function (id, title, body, icon, delay, onclick) {
            return null;
        }
    };

    // 检查一个微博是不是已经被显示过了,如果显示过了不重复显示
    var shownFeed = function (id) {
        return false;
    };

    // webkitNotifications
    // Tab Notifier 扩展实现此接口,但显示的桌面提示最多只能显示前两行
    if (typeof webkitNotifications !== 'undefined') avaliable.webkit = {
        'hasPermission': function () {
            return [true, null, false][webkitNotifications.checkPermission()];
        },
        'requestPermission': function (callback) {
            return webkitNotifications.requestPermission(callback);
        },
        'hideNotification': function (notify) {
            notify.cancel();
            afterHideNotification(notify);
        },
        'showNotification': function (id, title, body, icon, delay, onclick) {
            if (shownFeed(id)) return null;
            var notify = webkitNotifications.createNotification(icon, title, body);
            if (delay && delay > 0) notify.addEventListener('display', function () {
                setTimeout(function () {
                    hideNotification(notify);
                }, delay);
            });
            if (onclick) notify.addEventListener('click', onclick);
            notify.show();
            return notify;
        },
    };

    // Notification
    // Firefox 22+
    // 显示4秒会自动关闭 https://bugzil.la/875114
    if (typeof Notification !== 'undefined') avaliable.standard = {
        'hasPermission': function () {
            return {
                'granted': true,
                'denied': false,
                'default': null,
            }[Notification.permission];
        },
        'requestPermission': function (callback) {
            return Notification.requestPermission(callback);
        },
        'hideNotification': function (notify) {
            notify.close();
            afterHideNotification(notify);
        },
        'showNotification': function (id, title, body, icon, delay, onclick) {
            if (shownFeed(id)) return null;
            var notify = new Notification(title, {'body': body, 'icon': icon, 'requireInteraction': !delay});
            if (delay && delay > 0) notify.addEventListener('show', function () {
                setTimeout(function () {
                    hideNotification(notify);
                }, delay);
            });
            if (onclick) notify.addEventListener('click', onclick);
            return notify;
        },
    };

    // 有哪些接口可用
    var avaliableNotification = function () {
        return Object.keys(avaliable);
    };
    // 选择用哪个接口
    var choseNotification = function (prefer) {
        return (use = prefer && avaliable[prefer] || avaliable.standard);
    };
    choseNotification();
    // 检查权限
    var hasPermission = function () {
        return use.hasPermission.apply(this, arguments);
    };
    // 请求权限
    var requestPermission = function () {
        return use.requestPermission.apply(this, arguments);
    };
    // 显示消息
    var showNotification = function (title, body, icon, delay, onclick) {
        var notify = use.showNotification.apply(this, arguments);
        shown.push(notify);
        return notify;
    };
    // 隐藏已经显示的消息
    var hideNotification = function (notify) {
        use.hideNotification.apply(this, arguments);
        return notify;
    };
    var afterHideNotification = function (notify) {
        shown = shown.filter(function (x) {
            return x !== notify;
        });
    };

    document.addEventListener('unload', function () {
        shown.forEach(hideNotification);
        shown = [];
    });

    return {
        'avaliableNotification': avaliableNotification,
        'choseNotification': choseNotification,
        'hasPermission': hasPermission,
        'requestPermission': requestPermission,
        'showNotification': showNotification,
        'hideNotification': hideNotification
    };

}());

function showNotification() {
    switch (notify.hasPermission()) {
        case null: // default
            var thatArguments = arguments;
            notify.requestPermission(function () {
                showNotification.apply(null, thatArguments);
            });
            break;
        case true: // granted
            notify.showNotification.apply(null, arguments);
            break;
        case false: // denied
            log('Notification permission: denied');
            break;
    }
}

function tryBangumiRedirect() {
    const one_api = api._get_view;
    const originUrl = location.pathname;
    api.asyncAjax(one_api, originUrl, function(result) {
        if (result === null) {
            console.error('Bangumi redirect failed.'); //No bangumi in api response
        } else {
            bangumiRedirect(result.season_id, result.ep_index);
        }
    }, console.error);
}

function bangumiRedirect(season_id, ep_index) {
    $.ajax({
        url: biliplusHost + '/api/bangumi?season=' + season_id,
        async: true,
        xhrFields: {withCredentials: true},
        success: function (result) {
            log('==>', result);
            if (result.code !== 0) {
                log('error', result);
                return;
            }
            result = result.result;

            const episode_ids = result.episodes.map(ep => ep.episode_id);
            episode_ids.reverse();
            const episode_id = episode_ids[ep_index];
            location.href = '//bangumi.bilibili.com/anime/' + season_id + '/play#' + episode_id;
        },
        error: function (e) {
            log('error', arguments, this);
        }
    });
}