// ==UserScript==
// @name 自用bilibili脚本
// @namespace mimiko/bilibili
// @version 0.0.27
// @description 吧啦吧啦
// @author Mimiko
// @license MIT
// @match *://*.bilibili.com/*
// @grant GM.addStyle
// @grant GM.xmlHttpRequest
// grant GM.registerMenuCommand
// @run-at document-end
// ==/UserScript==
// https://greasyfork.org/zh-CN/scripts/436748-%E8%87%AA%E7%94%A8bilibili%E8%84%9A%E6%9C%AC
// interface
"use strict";
(() => {
if (window.top !== window.self) return;
// variables
/** 工具类 */ const Z = {
/** 添加样式 */ addStyle: (listContent, ...listItem) => {
const content = listContent
.map((it, i) => `${it}${listItem[i] ?? ""}`)
.join("");
GM.addStyle(content);
},
/** 防抖函数 */ debounce: (fn, delay) => {
let timer = 0;
return (...args) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => fn(...args), delay);
};
},
/** 输出日志 */ echo: (...args) => console.log(...args),
/** 发起请求 */ get: (url, data = {}) =>
new Promise((resolve) => {
GM.xmlHttpRequest({
method: "GET",
url: `${url}?${new URLSearchParams(
Object.entries(data).map((group) => [group[0], String(group[1])]),
).toString()}`,
onload: (res) => resolve(JSON.parse(res.responseText).data),
onerror: () => resolve(undefined),
});
}),
/** 获取元素;异步函数,会重复执行直至目标元素出现 */ getElements: (
selector,
) =>
new Promise((resolve) => {
const fn = () => {
if (document.hidden) return;
const $el = document.querySelectorAll(selector);
if (!$el.length) return;
window.clearInterval(timer);
resolve([...$el]);
};
const timer = window.setInterval(fn, 50);
fn();
}),
/** 隐藏元素;通过在目标元素上写入内联样式实现 */ hideElements: (
...listSelector
) => {
Z.addStyle`
${listSelector.map((selector) => `${selector} { display: none !important; }`).join("\n")}
`;
},
/** 从本地存储中读取数据 */ load: (name) => {
try {
const data = localStorage.getItem(`mimiko-gms/${name}`);
if (!data) return null;
return JSON.parse(data);
} catch (e) {
alert(`读取缓存失败:${e.message}`);
return null;
}
},
/** 移除元素 */ removeElements: (selector) =>
document.querySelectorAll(selector).forEach(($it) => $it.remove()),
/** 运行函数 */ run: (fn) => fn(),
/** 保存数据到本地存储 */ save: (name, data) =>
localStorage.setItem(`mimiko-gms/${name}`, JSON.stringify(data)),
/** 等待一段时间 */ sleep: (ts) =>
new Promise((resolve) => setTimeout(resolve, ts)),
};
/** 黑名单 */ const Blacklist = {
list: [],
/** 清空黑名单 */ clear: () => {
if (!User.isLogin) return;
Z.save("list-blacklist", []);
Z.save("time-blacklist", 0);
},
/** 判断是否在黑名单中 */ has: (name) => Blacklist.list.includes(name),
/** 加载黑名单 */ load: async () => {
if (!User.isLogin) return;
const time = Z.load("time-blacklist");
if (!time || Date.now() - time > 864e5) {
await Blacklist.update();
await Blacklist.load();
return;
}
Blacklist.list = Z.load("list-blacklist") ?? [];
},
/** 保存黑名单 */ save: () => {
if (!User.isLogin) return;
Z.save("list-blacklist", Blacklist.list);
Z.save("time-blacklist", Date.now());
},
/** 加载黑名单 */ update: async () => {
const ps = 50;
const fetcher = async (pn = 0) => {
if (!User.isLogin) return [];
const result = await Z.get(
"https://api.bilibili.com/x/relation/blacks",
{
pn,
ps,
},
);
if (!result) return [];
const list = result.list.map((it) => it.uname);
if (list.length >= ps) return [...list, ...(await fetcher(pn + 1))];
return list;
};
Blacklist.list = await fetcher();
Blacklist.save();
},
};
/** 本地存储;目前用于记录哪些视频已经在首页出现过 */ const Cache = {
/** 记录视频上限 */ limit: 5e4,
/** 视频列表 */ list: Z.load("cache-recommend") ?? [],
/** 添加视频 */ add: (id) => {
const it = Cache.get(id);
const it2 = [id, it[1] + 1];
const list2 = Cache.list.filter((it) => it[0] !== id);
list2.push(it2);
Cache.list = list2.slice(-Cache.limit);
},
/** 清空视频 */ clear: () => {
Cache.list = [];
Cache.save();
},
/** 获取视频 */ get: (id) => {
return Cache.list.find((it) => it[0] === id) ?? [id, 0];
},
/** 保存记录 */ save: () => {
Z.save("cache-recommend", Cache.list);
},
};
/** 路由 */ const Router = {
/** 计数 */ count: {
index: 0,
unknown: 0,
video: 0,
},
/** 路由表 */ map: {
index: [],
unknown: [],
video: [],
},
/** 路由 */ path: "",
/** 初始化 */ init: () => {
const fn = () => {
const { pathname } = window.location;
if (pathname === Router.path) return;
Router.path = pathname;
const name = Z.run(() => {
if (Router.is("index")) return "index";
if (Router.is("video")) return "video";
return "unknown";
});
if (name === "unknown") return;
Router.map[name].forEach(async (it) => {
if (Router.count[name] === 0) it[1] = await it[0]();
if (typeof it[2] === "function") it[2]();
if (typeof it[1] === "function") it[2] = await it[1]();
Router.count[name]++;
});
};
window.setInterval(fn, 200);
return Router;
},
/** 判断路由 */ is: (name) => {
const { pathname } = window.location;
if (name === "index") return ["/", "/index.html"].includes(pathname);
if (name === "video") return pathname.startsWith("/video/");
return true;
},
/** 监听路由 */ watch: (name, fn) => {
Router.map[name].push([fn, undefined, undefined]);
return Router;
},
};
/** 用户 */ const User = {
isLogin: false,
/** 更新用户状态 */ update: async () => {
const result = await Z.get(
"https://api.bilibili.com/x/web-interface/nav",
);
if (!result) return;
User.isLogin = result.isLogin;
},
};
// functions
/** 首页 */ const asIndex = async () => {
Z.addStyle`
body { min-width: auto; }
.container:first-child { display: none; }
.feed-roll-btn button:first-of-type { height: ${Z.run(() => {
const picture = document.querySelector(".feed-card picture");
if (!picture) return 135;
const { height } = picture.getBoundingClientRect();
return height;
})}px !important; }
.flexible-roll-btn { display: none !important; }
.feed-card { display: block !important; margin-top: 0 !important; }
.feed-card.is-hidden { position: relative; }
.feed-card.is-hidden .bili-video-card {
transition: opacity 0.3s;
opacity: 0.1;
}
.feed-card.is-hidden .bili-video-card:hover { opacity: 1; }
.feed-card.is-hidden .reason {
position: absolute;
width: 160px;
height: 32px;
left: 50%;
top: 32%;
text-align: center;
line-height: 32px;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
transform: translate(-50%, -50%);
transition: opacity 0.3s;
z-index: 1;
}
.feed-card.is-hidden:hover .reason { opacity: 0; }
`;
// container
const [container] = await Z.getElements(".container");
const ctn = document.createElement("div");
container.classList.forEach((it) => ctn.classList.add(it));
container.parentNode?.append(ctn);
// add cache clear button
const [groupBtn] = await Z.getElements(".feed-roll-btn");
const btnClear = document.createElement("button");
btnClear.classList.add("primary-btn", "roll-btn");
btnClear.innerHTML = "<span>✖</span>";
btnClear.setAttribute("title", "清空缓存");
btnClear.addEventListener("click", () => {
Blacklist.clear();
Cache.clear();
alert("缓存已清空");
});
btnClear.style.marginTop = "20px";
groupBtn.append(btnClear);
return async () => {
await User.update();
await Blacklist.load();
const hide = () => {
observer.disconnect();
const listItem = [...container.children].filter((it) =>
it.classList.contains("feed-card"),
);
listItem.forEach((it) => {
const check = () => {
const author =
it.querySelector(".bili-video-card__info--author")?.textContent ??
"";
if (!author) return "UP主不存在";
if (Blacklist.has(author)) return "UP主在黑名单中";
// play count too low
const text =
it.querySelector(".bili-video-card__stats")?.textContent ?? "";
if (!text) return "播放量不存在";
if (!text.includes("万")) return "播放量不足1万";
const amount = parseFloat(text.split("万")[0]);
if (amount < 3) return "播放量不足3万";
// has been viewed
const link = it.querySelector("a");
if (!link) return "链接不存在";
// info
const info = it.querySelector(".bili-video-card__info--icon-text");
const id = link.href.replace(/.*\/BV/, "").replace(/\/\?.*/, "");
if (Cache.get(id)[1] > (info?.textContent === "已关注" ? 2 : 0))
return `已出现${Cache.get(id)[1]}次`;
Cache.add(id);
return "";
};
const reason = check();
if (!reason) {
ctn.prepend(it);
return;
}
if (reason === "播放量不存在") return;
it.classList.add("is-hidden");
const tip = document.createElement("div");
tip.classList.add("reason");
tip.textContent = reason;
it.append(tip);
ctn.append(it);
});
Cache.save();
observer.observe(container, {
childList: true,
});
};
const observer = new MutationObserver(hide);
hide();
return () => observer.disconnect();
};
};
/** 视频页 */ const asVideo = () => {
Z.addStyle`
.video-share-popover { display: none; }
`;
// functions
const execFS = async () => {
const [btn] = await Z.getElements(".bpx-player-ctrl-web");
btn.click();
};
const execWS = async () => {
const [btn] = await Z.getElements(".bpx-player-ctrl-wide");
btn.click();
};
// 自动全屏
const autoFS = Z.debounce(async () => {
const isNarrow = window.innerWidth <= 1080;
if (!isNarrow) return;
const [player] = await Z.getElements("#bilibili-player");
if (player.classList.contains("mode-webscreen")) return;
await execFS();
}, 1e3);
// 注册热键
const bindKey = (e) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
if (["q", "w", "e", "d", "f", "m"].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
}
if (e.key === "f") {
execFS();
return;
}
if (e.key === "w") execWS();
};
return () => {
autoFS();
window.addEventListener("resize", autoFS);
document.addEventListener("keydown", bindKey);
return () => {
window.removeEventListener("resize", autoFS);
document.removeEventListener("keydown", bindKey);
};
};
};
// 执行
Z.run(() => {
if (window.location.hostname !== "www.bilibili.com") return;
Router.watch("index", asIndex).watch("video", asVideo).init();
});
})();