一. 前言
本文是对Kyouichirou/BiliBili_Optimizer: enjoy and control bilibili (github.com)脚本对于不登陆实现近乎"无缝"观看高清视频和拦截登录窗口的逆向解析逻辑.
是该脚本的技术解析之二, JavaScript进阶-拦截和修改fetch response内容-以B站为例 | Lian.
无意侵犯B站(毕竟这是唯一一家还在做视频的网站, 还活着也不容易)权益, 文本仅作为交流学习使用, 用于展示ES6+
之后JavaScript
的强大和灵活.
很难相信在这个动辄大数据, 吉比特级别光纤宽带/带宽, 颠倒神鬼的AI..., 各种黑科技秒天秒地的时代, 几十年前的360P视频依然是国内"视频"网站(VIP的层级还是不够多)的主流, 显得颇为复古(虽然什么720P, 1080P, 4K, 蓝光也是严重缩水, 但直接端360P上来....呵呵).
二. 问题
原本以为这是个简单的定时器问题, 但是B站在弹窗诱导登陆上还是花费了一定的心思的.
在代码上进行了多层的冗余, 确保登录弹窗能够出现.
2.1 试看高清
这一点很重要, 这是突破B站不登陆观看高清的一个小小的"漏洞", 大概率在未来的不久, B站会堵上这个缺口.
试看30s后, 即会产生一个提示登录的弹窗的出现.
2.2 定时弹窗
这个不管有没有操作, 1分钟后都会产生这个弹窗.
2.3 需要解决的问题
- 60秒后出现的弹窗
- 弹窗的同时视频播放被暂停
- 全屏模式下会被强制退出
- 有限时间观看高清视频
- 每次重启浏览器后, 弹幕被默认开启
- 高清观看次数限制
三. 问题分析
实际上在涉及到js逆向分析本质上是一种社会学工程, 通过一定的手段找到代码的缺口.
很多看似不起眼的特征都可以用于作为逆向工程的重要佐证.
3.1 登录
通过控制面板 - 网络 - 查找, bili-mini-mask
可以找到一个位置使用了该值, 可以看到这个脚本中有大量login
相关名称的函数, 可大致推断这个脚本和弹窗存在很大的关系.
找到了可以的脚本, 那么在source
中找到这个脚本, 逐个对最可疑的函数进行断点调试.
可以看到大量的函数都在使用这个变量作为判断用户登录的特征, 那么干预这个特征, 很可能可以干预弹窗.
this.userInfo.isLogin
另一方面, 页面初始载入时, 弹窗的脚本节点和div
节点都是不存在的.
以该fs/seed/jinkela/short/mini-login-v2/miniLogin.umd.min.js
搜索
进一步得到api.bilibili.com/x/web-interface/nav, 打开.
{
"code": -101,
"message": "账号未登录",
"ttl": 1,
"data": {
"isLogin": false,
"wbi_img": {
"img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png",
"sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
}
}
}
可以看到页面向该url发起了请求的.
return new Promise((function(t, n) {
var r = new XMLHttpRequest;
if (r.onreadystatechange = function() {
if (r.readyState === XMLHttpRequest.DONE)
if (200 === r.status) {
var e = null;
try {
e = JSON.parse(r.responseText)
} catch (t) {
e = r.responseText
}
t(e)
} else
n(r.status)
}
,
r.onerror = function(t) {
n(t)
}
,
r.withCredentials = !0,
r.open(e, s, !0),
"POST" === e && i && "object" === c()(i))
for (var a in i)
r.setRequestHeader(a, i[a]);
r.send(o)
}
))
}
找到发起请求的源, 是通过XMLHttpRequest
发起的, 干预这个请求返回操作即可.
另一方面这个脚本是如何添加到html的.
<script type="text/javascript" src="//s1.hdslb.com/bfs/seed/jinkela/short/mini-login-v2/miniLogin.umd.min.js"></script>
以text/javascript
检索
t.prototype.requestQuickLoginPromise = function(e) {
return (0,
n.mG)(this, void 0, void 0, (function() {
var t = this;
return (0,
n.Jh)(this, (function(r) {
if (b.X.mobile.alike && !b.X.iPad.alike)
throw this.rootStore.parent.location.href = W.tn,
new Error("Redirect to login page");
return this.uiStore.isFullView && this.portStore.reqStatue(null),
[2, this.getQuickLoginScript("MiniLogin", W.x).then((function() {
return new Promise((function(r, n) {
var i = new t.rootStore.parent.MiniLogin(e ? {
customTitle: e
} : {})
, o = function() {
i && (i.removeEventListener("success", a),
i.removeEventListener("cancel", s))
}
, a = function() {
o(),
queueMicrotask((function() {
t.fetchUserDetails().then(r).catch(n)
}
))
}
, s = function() {
o(),
n(new Error("User cancelled"))
};
i.showComponent(),
i.addEventListener("success", a),
i.addEventListener("cancel", s)
}
))
}
)).then((function() {
t.rootPlayer.emit(a.t.Player_Access_Changed, new CustomEvent(a.t.Player_Access_Changed))
}
)).catch((function(e) {
return t.log.w(e && e.message),
Promise.reject(e)
}
))]
}
))
}
))
}
,
t.prototype.getQuickLoginScript = function(e, t) {
return (0,
n.mG)(this, void 0, void 0, (function() {
return (0,
n.Jh)(this, (function(r) {
return this.rootStore.parent[e] ? [2] : [2, (n = t,
i = this.rootStore.parent,
void 0 === i && (i = window),
new Promise((function(e, t) {
var r = i.document.createElement("script");
r.onload = e,
r.onerror = t,
r.type = "text/javascript",
r.async = !0,
r.crossOrigin = "anonymous",
r.src = n,
i.document.head.appendChild(r)
}
)))];
var n, i
}
))
}
))
}
,
i.document.head.appendChild(r)
通过appendChild()
函数实现的.
3.2 弹幕
关闭弹幕后, 每次重启浏览器都会重新打开弹幕, 一开始以为是cookie
, 但是检查cookie
并未发现可疑的内容.
那么怀疑是session localstorage
但是还是没有.
由于关闭弹幕后, 当前会话是不会自动开启的, 那么一定是使用了存储来记录, 排除了前面二者, 那么一定是使用了localstorage
来存储这个记录.
找到和弹幕相关的设置, 但是很多变量, 反复点击开启/关闭弹幕
dmSetting.dmSwitch
这个值是控制弹幕的, 干预就很简单了.
3.3 高清试看
同理, 根据关键词查找, 可以很开找出相关的代码.
t.trailSwitchTimer = window.setTimeout((function() {
t.qualityStore.requestQuality(16).then((function() {
t.qualityStore.setState({
isTrialSwitch: !1
});
t.toastStore.create({
duration: 5e3,
manualMode: !0,
text: "高清试看已结束, 为您切换回360P 流畅"
}),
t.trialLoginTimer = window.setTimeout((function() {
t.userStore.isLogin || (t.portStore.pause(),
t.userStore.requestQuickLogin("高清试看已结束, 登录免费享高清画质")),
t.reportTrack("thirtyseconds_end_login_show", "appear")
}
), 4e3)
}
)).catch((function() {
t.qualityStore.setState({
isTrialSwitch: !1
})
}
))
}
)))
在上述代码中, 可以找到几个关键线索:
t.qualityStore.requestQuality(16)
发起请求视频清晰度的方式
window.setTimeout((function()
定时结束后执行的动作.
继续翻代码, 这些代码很容易理解, 开始和结束试用.
e.prototype.endTrial = function() {
var t = this;
this.qualityStore.state.isTrialQuality && (this.kernelStore.dashKernel && (this.kernelStore.dashKernel.setAutoSwitchQualityFor("video", !0).setAutoSwitchTopQualityFor("video", this.unLoginGreatestQuality),
this.hasTrialed = !0),
this.dp.et = (0,
o.gx)((function() {
return !t.qualityStore.isSwitching
}
), (function() {
t.qualityStore.setState({
isTrialQuality: !1
}),
t.qualityStore.realQ > t.unLoginGreatestQuality && (t.qualityStore.setState({
isTrialSwitch: !0
}),
t.reportTrack("thirtyseconds_end_show", "appear"),
t.trailSwitchTimer = window.setTimeout((function() {
t.qualityStore.requestQuality(16).then((function() {
t.qualityStore.setState({
isTrialSwitch: !1
});
t.toastStore.create({
duration: 5e3,
manualMode: !0,
text: "高清试看已结束, 为您切换回360P 流畅"
}),
t.trialLoginTimer = window.setTimeout((function() {
t.userStore.isLogin || (t.portStore.pause(),
t.userStore.requestQuickLogin("高清试看已结束, 登录免费享高清画质")),
t.reportTrack("thirtyseconds_end_login_show", "appear")
}
), 4e3)
}
)).catch((function() {
t.qualityStore.setState({
isTrialSwitch: !1
})
}
))
}
)))
}
)))
}
e.prototype.startTrialHq = function() {
var t = this;
this.dp.st = (0,
o.gx)((function() {
return !t.qualityStore.isSwitching
}
), (function() {
t.qualityStore.setState({
isTrialQuality: !0,
isTrialSwitch: !0
}),
t.trailSwitchTimer = window.setTimeout((function() {
t.qualityStore.requestQuality(t.highestQuality.quality).then((function() {
t.trialTimer = window.setTimeout(t.endTrial, 3e4),
t.reportTrack("thirtyseconds_click", "click", {
success: 1
}),
t.qualityStore.setState({
isTrialSwitch: !1
})
}
)).catch((function() {
t.qualityStore.setState({
isTrialQuality: !1
}),
t.reportTrack("thirtyseconds_click", "click", {
success: 0
}),
t.qualityStore.setState({
isTrialSwitch: !1
})
}
))
}
))
}
))
}
,
t.trialTimer = window.setTimeout(t.endTrial, 3e4)
30秒的定时, 试用的时间.
t.qualityStore.requestQuality(t.highestQuality.quality)
请求清晰度最高的视频.
3.3.1 试看次数
此外当试看的次数到一定的数值时, 试用的弹窗就不会出现.
以登录免费
查找
e.prototype.showLoginToast = function() {
var t = this
, e = this.toastStore.create({
duration: 5e3,
manualMode: !0,
confirmText: "立即登录",
text: "登录免费享高清画质",
onConfirmClicked: function() {
t.toastStore.remove(e),
t.userStore.requestQuickLogin(),
t.portStore.pause(),
t.reportTrack("play_window.login_toast_click", "click")
}
});
return this.reportTrack("play_window.login_toast_show", "appear"),
e
}
继续以可疑函数名showLoginToast()
查找, 可以看到
function() {
if (!(e.userStore.isLogin || e.isViewToday && e.isVideoAble)) {
var t = e.showLoginToast();
e.interactStore.setState({
unLoginToastId: t
})
}
}
用户是否已经登录或者是e.isViewToday && e.isVideoAble
这两组值决定了是否弹窗显示试看.
通过断点调试进一步找到这个值的读取过程.
Object.defineProperty(e.prototype, "isViewToday", {
get: function() {
if (this.hitAbTest)
return !0;
if (this.hasTrialed)
return !1;
var t = this.localStore.get().lastUnlogintrialView;
if (!t)
return !0;
var e = new Date(t)
, i = new Date;
return e.getDate() !== i.getDate() || e.getMonth() !== i.getMonth() || e.getFullYear() !== i.getFullYear()
},
enumerable: !1,
configurable: !0
})
即 通过干预Object.defineProperty
这个函数可以实现对上述值的干预.
3.4 暂停
暂停不是完全独立的, 而是在实现上述登录, 高清试看结束时顺带完成的
以pause()
为关键词查找, 可以看到多个函数调用了视频暂停这个函数, 要干预暂停就要可以控制弹窗和试看这两个核心部分.
四. 解决
4.1 登录
拦截修改XMLHttpRequest
, 由于这个对象并不像fetch
支持直接的json
处理, 返回的是文本内容. 修改返回的this.responseText
_xmlrequest: () => {
// B站视频播放页相关视频的数据请求, 从fetch改回xmlrequest
// 首次载入, 加载一次数据, 在登录账户时
// 第二次加载相关视频推荐时, 会请求两次数据
const handle_fetch_url = this.#configs.handle_fetch_url,
clear_data = this.#utilities_module.clear_data.bind(this.#utilities_module),
pre_data_check = this.#configs.pre_data_check,
check_user_login_api = this.#configs.check_user_login_api,
user_is_login = this.#user_is_login;
// 不登陆的状态下, 会发起可能多达3次的请求, 也可能不请求数据, 非常诡异...
GM_Objects.window.XMLHttpRequest = class extends GM_Objects.window.XMLHttpRequest {
constructor() {
super();
this.addEventListener('readystatechange', () => {
if (this.readyState === 4 && this.status === 200) {
let json = null;
// 拦截首页的登录弹窗
if (!user_is_login && this.responseURL === check_user_login_api) {
json = {
"code": 0,
"message": "0",
"ttl": 1,
"data": {
"isLogin": true,
"mid": 441644010,
"uname": "打着手电筒看书",
"face": "https://i0.hdslb.com/bfs/face/67ceb14021cfbc0b2a9e40b4b254b0a3be428b46.jpg",
"face_nft": 0,
"face_nft_type": 0,
"wbi_img": {
"img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png",
"sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
}
}
};
} else {
const url = this.responseURL, func = handle_fetch_url(url);
if (func) {
json = JSON.parse(this.responseText);
const spec = json.Spec;
func(json, pre_data_check);
// 清除掉这个广告内容
if (spec) clear_data(spec);
}
}
json && Object.defineProperty(this, 'responseText', { get() { return JSON.stringify(json); }, set(_val) { } });
}
});
}
};
}
需要注意的是, 上述的拦截操作会在视频播放页面出现未登录
的提示弹窗, 需要添加css
隐藏掉弹窗.
.van-message.van-message-error,
{
display: none !important;
}
4.1.1 弹窗
此外还不能完全杜绝登录弹窗.
[
Node.prototype,
'appendChild',
{
apply(target, thisArg, args) {
const node = args[0];
node.tagName?.toLowerCase() === 'script' && node.src.includes('miniLogin') ? setTimeout(() => unsafeWindow.player.play(), 100) : target.apply(thisArg, args);
}
}
]
拦截appendChild
的操作.
4.2 弹幕
这个比较简单, 就是拦截localstorage
的修改.
const origin_localstorage_setitem = localStorage.setItem;
const proxy_localstorage_setitem = Proxy.revocable(localStorage.setItem, {
apply(target, thisArg, args) {
if (args[0] === 'bpx_player_profile') {
const json = JSON.parse(args[1]);
if (json.dmSetting?.dmSwitch) {
proxy_localstorage_setitem.revoke();
localStorage.setItem = origin_localstorage_setitem;
json.dmSetting.dmSwitch = false;
args[1] = JSON.stringify(json);
}
}
return Reflect.apply(target, thisArg, args);
}
}
);
localStorage.setItem = proxy_localstorage_setitem.proxy;
4.3 持续高清
依赖的是settimeout
, 定时30s(30000ms/3e4ms)
[
unsafeWindow,
'setTimeout',
{
apply(target, thisArg, argArray) {
const [fn, time] = argArray;
return target.apply(thisArg, [fn, [4000, 30000].includes(time) ? 1e8 : time]);
}
}
]
4.3.1 更为友好切换高清
为了让切换高清显得更为顺滑, 需要精确判断试看按钮出现的时间.
_monitor_trial(node) {
new MutationObserver((records) => {
let f = false;
for (const r of records) {
for (const node of r.addedNodes) {
if (!this._is_click && this._trial_btn_click(node)) {
f = true;
break;
} else if (this._is_click && this._check_switch_finished(node)) {
this._is_click = false;
f = true;
this._timeout_id && clearTimeout(this._timeout_id);
this._replay_video();
break;
}
}
if (f) break;
}
}).observe(node, { childList: true, subtree: true });
}
监听节点的变化, 用于控制试看按钮的点击, 以及切换到高清需要稍微等待视频的加载, 需要暂停视频, 当切换成功, 重新播放视频.
e.prototype.showTrialToast = function() {
var t = this
, e = document.createElement("span");
e.textContent = "登录 ",
e.classList.add("".concat(this.ppx, "-toast-confirm")),
e.style.marginLeft = "0px";
var i = document.createElement("span");
i.textContent = "免费享高清视频",
i.insertAdjacentElement("afterbegin", e);
var n = document.createElement("span");
n.classList.add("".concat(this.ppx, "-toast-confirm-login")),
n.innerHTML = "试看30秒";
var r = this.toastStore.create({
duration: this.hitAbTest ? 0 : 12e3,
manualMode: !0,
confirmText: n,
text: i,
onConfirmClicked: function() {
t.toastStore.remove(r),
t.localStore.set({
lastUnlogintrialView: Date.now()
}),
t.startTrialHq()
}
});
return e.addEventListener("click", (function() {
t.toastStore.remove(r),
t.userStore.requestQuickLogin(),
t.portStore.pause(),
t.reportTrack("thirtyseconds_login_click", "click")
}
)),
this.reportTrack("thirtyseconds_show", "appear"),
r
}
需要精确监听指定的节点的变化还需要进一步细化
i.insertAdjacentElement("afterbegin", e);
可以看到这个插入元素的操作, 通过拦截这个操作, 获得插入的节点元素, 反向获取这个插入节点的父元素进而获得精确的节点以及对试看播放等的控制.
const origin_obj = HTMLElement.prototype.insertAdjacentElement;
// 创建可撤销的proxy, 只执行一次即可
const proxy_obj = Proxy.revocable(HTMLElement.prototype.insertAdjacentElement, {
apply: (target, thisArg, args) => {
let node = args[1];
if (args[0] === 'afterbegin' && node.tagName === 'SPAN' && node.className === 'bpx-player-toast-confirm') {
// 撤销掉代理即可
proxy_obj.revoke();
HTMLElement.prototype.insertAdjacentElement = origin_obj;
// 必须采用回调的方式实现, 因为这个时候插入节点函数尚未执行, 所以当前插入的节点的父节点尚未生成
setTimeout(() => {
while (true) {
let pnode = node.parentNode;
if (!pnode) {
Colorful_Console.print('trial button monitor no target node', 'warning');
break;
}
if (pnode.className === 'bpx-player-toast-auto') {
// 先创建节点变化的监听
this._monitor_trial(pnode);
// 直接模拟点击, 而不是等待上面的节点监听触发, 因为创建监听时, 相对应的节点已经创建好了, 无法监听到
!this._is_click && this._trial_btn_click(pnode);
break;
}
node = pnode;
}
});
}
return Reflect.apply(target, thisArg, args);
}
});
// 可撤销的proxy的实现方式比较蠢
HTMLElement.prototype.insertAdjacentElement = proxy_obj.proxy;
整个过程并不希望这些按钮和提示反复出现, 添加css
将所有的内容都设置为透明, 不影响click
.bpx-player-toast-item{opacity: 0.01 !important;}
4.4 高清试看次数
[
Object,
'defineProperty',
{
apply(target, thisArg, args) {
let [obj, prop, descriptor] = args;
if (prop === 'isViewToday' || prop === 'isVideoAble') {
descriptor = {
get: () => true,
enumerable: false,
configurable: true
};
}
return Reflect.apply(target, thisArg, [obj, prop, descriptor]);
}
}
]
4.5 完整代码
关于各种代理的拦截实现, 对于可撤销代理的创建, 相对巧妙的使用数组按地址传递参数的方式.
const proxy = (target, target_name, handler) => (target[target_name] = new Proxy(target[target_name], handler)),
proxy_revocable = (target, target_name, handler) => Proxy.revocable(target[target_name], handler),
get_proxy = (target, target_name, proxy_obj) => (target[target_name] = proxy_obj.proxy),
restore_target = (target, target_name, origin_obj) => (target[target_name] = origin_obj);
[
// 干预settimeout, 试用高清是通过固定时间的计时器来实现超时,4000ms用于暂停和退出视频; 30000用于终止试用=== 4000 || time === 30000 || time === 1500
[
unsafeWindow,
'setTimeout',
{
apply(target, thisArg, argArray) {
const [fn, time] = argArray;
return target.apply(thisArg, [fn, [4000, 30000].includes(time) ? 1e8 : time]);
}
}
],
// 干预试用次数, 一天播放到一定次数就会不显示试用
[
Object,
'defineProperty',
{
apply(target, thisArg, args) {
let [obj, prop, descriptor] = args;
if (prop === 'isViewToday' || prop === 'isVideoAble') {
descriptor = {
get: () => true,
enumerable: false,
configurable: true
};
}
return Reflect.apply(target, thisArg, [obj, prop, descriptor]);
}
}
],
// 干预定时登录弹窗和暂停视频, 由于弹窗的计时不是通过固定的数值settimeout或者setinterval来实现的, 这里间接通过拦截相关html元素的创建来实现操作
// 这里不采用干预上述的api的登录状态请求来干预弹窗, 因为干预api的方式会导致页面出现非登录的小弹窗提醒
[
Node.prototype,
'appendChild',
{
apply(target, thisArg, args) {
const node = args[0];
node.tagName?.toLowerCase() === 'script' && node.src.includes('miniLogin') ? setTimeout(() => unsafeWindow.player.play(), 100) : target.apply(thisArg, args);
}
}
]
].forEach(e => proxy(...e));
// 可撤销代理创建, 以下函数只在首次载入页面时使用
[
// 每次重启浏览器后, 弹幕都默认开启, localstorage中的bpx_player_profile都会被修改, dmSwitch都会被设置为true
[
localStorage,
'setItem',
(...args) => {
if (args[3][0] === 'bpx_player_profile') {
const json = JSON.parse(args[3][1]);
if (json.dmSetting?.dmSwitch) {
args[0][2].revoke();
restore_target(...args[0][1], args[0][0]);
json.dmSetting.dmSwitch = false;
args[3][1] = JSON.stringify(json);
}
}
return Reflect.apply(...args.slice(1));
}
],
// 由于需要精确监听节点的生成, 所以这里采用代理的方式来拦截某个插入节点的操作, 从而精确获得该节点生成的时间
[
HTMLElement.prototype,
'insertAdjacentElement',
(...args) => {
let node = args[3][1];
if (args[3][0] === 'afterbegin' && node.tagName === 'SPAN' && node.className === 'bpx-player-toast-confirm') {
// 撤销掉代理即可
args[0][2].revoke();
restore_target(...args[0][1], args[0][0]);
// 必须采用回调的方式实现, 因为这个时候插入节点函数尚未执行, 所以当前插入的节点的父节点尚未生成
setTimeout(() => {
while (true) {
let pnode = node.parentNode;
if (!pnode) {
Colorful_Console.print('trial button monitor no target node', 'warning');
break;
}
if (pnode.className === 'bpx-player-toast-auto') {
// 先创建节点变化的监听
this._monitor_trial(pnode);
// 直接模拟点击, 而不是等待上面的节点监听触发, 因为创建监听时, 相对应的节点已经创建好了, 无法监听到
!this._is_click && this._trial_btn_click(pnode);
break;
}
node = pnode;
}
});
}
return Reflect.apply(...args.slice(1));
}
]
].forEach(e => {
const [target, target_name, handler] = e, args = [];
// 原对象方法
args.push(target[target_name]);
// 原对象和对象方法名称
args.push([target, target_name]);
// 可撤销代理对象
// 这里巧妙利用数组的引用是基于地址引用, 修改数组的内容, 引用的函数也同样发生变化
// 即实现, 函数需要引用一个函数创建的对象作为参数(先后问题, 即引用一个尚未初始化的变量作为参数, 数组的按地址引用实现了这个矛盾的问题)
args.push(proxy_revocable(target, target_name, { apply: handler.bind(this, args) }));
get_proxy(target, target_name, args[2]);
});
五. 小结
上述相关问题主要由这两个脚本实现.
最终, 关于不登陆的完整处理, 添加了这部分功能后, 不登陆已经对于观看视频不产生负面影响, fuck 360P, fuck 账号.
anti_login: {
_is_click: false,
_timeout_id: null,
// 监听试看按钮的点击事件所导致的节点内容变化, 用于判断执行的进度
_monitor_trial(node) {
new MutationObserver((records) => {
let f = false;
for (const r of records) {
for (const node of r.addedNodes) {
if (!this._is_click && this._trial_btn_click(node)) {
f = true;
break;
} else if (this._is_click && this._check_switch_finished(node)) {
this._is_click = false;
f = true;
this._timeout_id && clearTimeout(this._timeout_id);
this._replay_video();
break;
}
}
if (f) break;
}
}).observe(node, { childList: true, subtree: true });
},
// 超时不触发上面的事件监听则主动播放
_wait_switch() {
this._timeout_id = setTimeout(() => {
const qs = unsafeWindow.player.getQuality();
if (qs.newQ > 16) unsafeWindow.player.play();
this._timeout_id = null;
}, 1500);
},
// 判断视频是否已经切换到高清
_check_switch_finished(node) { return node.innerText?.includes('试用中'); },
_trial_btn_click(node) {
const b = node.getElementsByClassName('bpx-player-toast-confirm-login');
return this._is_click = b.length > 0 && b[0].innerHTML.includes('试看') ? (setTimeout(() => {
const qs = unsafeWindow.player.getSupportedQualityList();
if (qs && qs.some(e => e > 16)) {
unsafeWindow.player.pause();
this._wait_switch();
}
b[0].click();
}, 100), true) : false;
},
_replay_video() { setTimeout(() => unsafeWindow.player.play()); },
init() {
// 拦截弹窗和暂停视频播放的情况:
// 1. 没有任何操作
// 2. 点击了试用按钮 3. 点击了试用按钮, 同时全屏或者是进行其他扩屏的操作 4. 不点击试用按钮, 但是全屏了
// 需要多个层级拦截操作而不是单个点
const proxy = (target, target_name, handler) => (target[target_name] = new Proxy(target[target_name], handler)),
proxy_revocable = (target, target_name, handler) => Proxy.revocable(target[target_name], handler),
get_proxy = (target, target_name, proxy_obj) => (target[target_name] = proxy_obj.proxy),
restore_target = (target, target_name, origin_obj) => (target[target_name] = origin_obj);
[
// 干预settimeout, 试用高清是通过固定时间的计时器来实现超时,4000ms用于暂停和退出视频; 30000用于终止试用=== 4000 || time === 30000 || time === 1500
[
unsafeWindow,
'setTimeout',
{
apply(target, thisArg, argArray) {
const [fn, time] = argArray;
return target.apply(thisArg, [fn, [4000, 30000].includes(time) ? 1e8 : time]);
}
}
],
// 干预试用次数, 一天播放到一定次数就会不显示试用
[
Object,
'defineProperty',
{
apply(target, thisArg, args) {
let [obj, prop, descriptor] = args;
if (prop === 'isViewToday' || prop === 'isVideoAble') {
descriptor = {
get: () => true,
enumerable: false,
configurable: true
};
}
return Reflect.apply(target, thisArg, [obj, prop, descriptor]);
}
}
],
// 干预定时登录弹窗和暂停视频, 由于弹窗的计时不是通过固定的数值settimeout或者setinterval来实现的, 这里间接通过拦截相关html元素的创建来实现操作
// 这里不采用干预上述的api的登录状态请求来干预弹窗, 因为干预api的方式会导致页面出现非登录的小弹窗提醒
[
Node.prototype,
'appendChild',
{
apply(target, thisArg, args) {
const node = args[0];
node.tagName?.toLowerCase() === 'script' && node.src.toLowerCase().includes('minilogin') ? setTimeout(() => unsafeWindow.player.play(), 100) : target.apply(thisArg, args);
}
}
]
].forEach(e => proxy(...e));
// 可撤销代理创建, 以下函数只在首次载入页面时使用
[
// 每次重启浏览器后, 弹幕都默认开启, localstorage中的bpx_player_profile都会被修改, dmSwitch都会被设置为true
[
localStorage,
'setItem',
(...args) => {
if (args[3][0] === 'bpx_player_profile') {
const json = JSON.parse(args[3][1]);
if (json.dmSetting?.dmSwitch) {
args[0][2].revoke();
restore_target(...args[0][1], args[0][0]);
json.dmSetting.dmSwitch = false;
args[3][1] = JSON.stringify(json);
}
}
return Reflect.apply(...args.slice(1));
}
],
// 由于需要精确监听节点的生成, 所以这里采用代理的方式来拦截某个插入节点的操作, 从而精确获得该节点生成的时间
[
HTMLElement.prototype,
'insertAdjacentElement',
(...args) => {
let node = args[3][1];
if (args[3][0] === 'afterbegin' && node.tagName === 'SPAN' && node.className === 'bpx-player-toast-confirm') {
// 撤销掉代理即可
args[0][2].revoke();
restore_target(...args[0][1], args[0][0]);
// 必须采用回调的方式实现, 因为这个时候插入节点函数尚未执行, 所以当前插入的节点的父节点尚未生成
setTimeout(() => {
while (true) {
let pnode = node.parentNode;
if (!pnode) {
Colorful_Console.print('trial button monitor no target node', 'warning');
break;
}
if (pnode.className === 'bpx-player-toast-auto') {
// 先创建节点变化的监听
this._monitor_trial(pnode);
// 直接模拟点击, 而不是等待上面的节点监听触发, 因为创建监听时, 相对应的节点已经创建好了, 无法监听到
!this._is_click && this._trial_btn_click(pnode);
break;
}
node = pnode;
}
});
}
return Reflect.apply(...args.slice(1));
}
]
].forEach(e => {
const [target, target_name, handler] = e, args = [];
// 原对象方法
args.push(target[target_name]);
// 原对象和对象方法名称
args.push([target, target_name]);
// 可撤销代理对象
// 这里巧妙利用数组的引用是基于地址引用, 修改数组的内容, 引用的函数也同样发生变化
// 即实现, 函数需要引用一个函数创建的对象作为参数(先后问题, 即引用一个尚未初始化的变量作为参数, 数组的按地址引用实现了这个矛盾的问题)
args.push(proxy_revocable(target, target_name, { apply: handler.bind(this, args) }));
get_proxy(target, target_name, args[2]);
});
}
}
B站至今尚未将代码进行高度混淆很难得, 作为一家大型网站.
毕竟防御性编程原来越成为"技术主流"以及和防御爬虫的现实需要.