为哔哩哔哩视频页面添加不喜欢(点踩)按钮
// ==UserScript==
// @name Bilibili Dislike
// @name:zh-CN 哔哩哔哩不喜欢(点踩)按钮
// @namespace https://gab.moe/
// @version 1.0.3
// @description 为哔哩哔哩视频页面添加不喜欢(点踩)按钮
// @author GabrielxD
// @match *://*.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @connect passport.bilibili.com
// @connect app.biliapi.net
// @require https://registry.npmmirror.com/qrcodejs/1.0.0/files/qrcode.min.js
// @require https://update.greasyfork.org/scripts/566236/1754467/Bilibili%20App%20Auth.js
// @run-at document-start
// @license MIT
// ==/UserScript==
(() => {
"use strict";
const logger = (() => {
const { name: scriptName, version: scriptVersion } = GM_info.script;
const nameStyle = `padding: 2px 10px; border-radius: 4px 0 0 4px; color: #fff; background: #2394F1; font-weight: bold;`;
const versionStyle = `padding: 2px 10px; border-radius: 0 4px 4px 0; color: #fff; background: #FA7298; font-weight: bold;`;
return new Proxy(console, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") {
return value;
}
return value.bind(
target,
`%c${scriptName}%cv${scriptVersion}`,
nameStyle,
versionStyle,
);
},
});
})();
GM_addStyle(
/* css */ `
.bili-dislike-qr-dialog {
border: none;
border-radius: 12px;
padding: 0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
background-color: #fff;
max-width: 340px;
width: 100%;
animation: bili-dislike-qr-fadein 0.2s ease;
}
.bili-dislike-qr-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
animation: bili-dislike-qr-fadein 0.2s ease;
}
@keyframes bili-dislike-qr-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.bili-dislike-qr-dialog[data-closing] {
animation: bili-dislike-qr-fadeout 0.2s ease forwards;
}
.bili-dislike-qr-dialog[data-closing]::backdrop {
animation: bili-dislike-qr-fadeout 0.2s ease forwards;
}
@keyframes bili-dislike-qr-fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.bili-dislike-qr-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px 0;
}
.bili-dislike-qr-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #18191c;
}
.bili-dislike-qr-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background-color: transparent;
color: #9499a0;
font-size: 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.bili-dislike-qr-close:hover {
background-color: #f1f2f3;
}
.bili-dislike-qr-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 24px 28px;
}
.bili-dislike-qr-code-wrapper {
position: relative;
width: 200px;
height: 200px;
border: 1px solid #e3e5e7;
border-radius: 8px;
overflow: hidden;
display: grid;
place-items: center;
}
.bili-dislike-qr-code-wrapper img,
.bili-dislike-qr-code-wrapper canvas {
display: block;
}
.bili-dislike-qr-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background-color: rgba(255, 255, 255, 0.92);
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.bili-dislike-qr-overlay[data-visible] {
opacity: 1;
pointer-events: auto;
}
.bili-dislike-qr-overlay-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 24px;
color: #fff;
}
.bili-dislike-qr-overlay-icon[data-state="scanned"],
.bili-dislike-qr-overlay-icon[data-state="success"] {
background-color: #00b578;
}
.bili-dislike-qr-overlay-icon[data-state="expired"],
.bili-dislike-qr-overlay-icon[data-state="error"] {
background-color: #9499a0;
}
.bili-dislike-qr-overlay-text {
font-size: 14px;
font-weight: 500;
color: #61666d;
}
.bili-dislike-qr-status {
margin: 16px 0 0;
font-size: 14px;
color: #61666d;
text-align: center;
line-height: 1.5;
min-height: 21px;
transition: color 0.2s;
}
.bili-dislike-qr-status[data-state="scanned"],
.bili-dislike-qr-status[data-state="success"] {
color: #00b578;
}
.bili-dislike-qr-status[data-state="expired"],
.bili-dislike-qr-status[data-state="error"] {
color: #f25d8e;
}
.bili-dislike-qr-tip {
margin: 8px 0 0;
font-size: 12px;
color: #9499a0;
text-align: center;
}
/* Hover refresh overlay */
.bili-dislike-qr-hover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
z-index: 2;
}
.bili-dislike-qr-code-wrapper:hover .bili-dislike-qr-hover {
opacity: 1;
}
.bili-dislike-qr-hover-text {
font-size: 14px;
font-weight: 500;
color: #61666d;
user-select: none;
}
/* When status icon is visible: no extra background, text at the bottom */
.bili-dislike-qr-code-wrapper[data-has-icon] .bili-dislike-qr-hover {
background: transparent;
align-items: flex-end;
padding-bottom: 14px;
}
.video-dislike-icon {
transform: scaleY(-1);
}
`.trim(),
);
async function waitForSelector(root, selectors, timeout = 1000) {
const node = root.querySelector(selectors);
if (node) {
return node;
}
return new Promise(res => {
let task = void 0;
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
if (mutation.target instanceof Element) {
const node2 = mutation.target.querySelector(selectors);
if (node2) {
observer.disconnect();
window.clearTimeout(task);
return res(node2);
}
}
}
});
observer.observe(root, {
attributes: true,
childList: true,
subtree: true,
});
if (timeout !== void 0) {
task = window.setTimeout(() => {
observer.disconnect();
res(null);
}, timeout);
}
});
}
async function waitForVue2(elOrSelector, options) {
const { interval = 50, timeout = 1000 } = options || {};
const startTS = Date.now();
let el = elOrSelector;
if (typeof elOrSelector === "string") {
el = await waitForSelector(document.documentElement, elOrSelector);
}
const findVue = () => {
let node = el;
while (node !== document.documentElement) {
if (typeof node?.__vue__?.$nextTick === "function") {
return node?.__vue__;
}
node = node.parentElement;
}
return null;
};
if (findVue()) {
return true;
}
return new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const done = () => {
if (settled) return;
settled = true;
if (timer) {
clearInterval(timer);
timer = null;
}
};
timer = setInterval(() => {
if (Date.now() - startTS > timeout) {
done();
throw new Error("Timeout");
}
findVue()?.$nextTick?.(() => {
done();
resolve();
});
}, interval);
});
}
function toast(text, duration = 3000) {
return unsafeWindow.player.toast.create({ text, duration });
}
/**
* 创建并展示扫码登录 Dialog
* @param {string} qrcodeUrl 二维码内容 URL
* @param {{ onClose?: () => void, onRefresh?: () => Promise<string | null> }} callbacks
* @returns {{ dialog: HTMLDialogElement, setStatus: Function, close: Function }}
*/
function createQrcodeDialog(qrcodeUrl, { onClose, onRefresh } = {}) {
const dialog = document.createElement("dialog");
dialog.className = "bili-dislike-qr-dialog";
dialog.innerHTML = /* html */ `
<div class="bili-dislike-qr-header">
<h3 class="bili-dislike-qr-title">扫码登录</h3>
<button class="bili-dislike-qr-close" title="关闭">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="m12 13.4l-4.9 4.9q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l4.9-4.9l-4.9-4.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l4.9 4.9l4.9-4.9q.275-.275.7-.275t.7.275t.275.7t-.275.7L13.4 12l4.9 4.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275z"/></svg>
</button>
</div>
<div class="bili-dislike-qr-body">
<div class="bili-dislike-qr-code-wrapper">
<div class="bili-dislike-qr-code"></div>
<div class="bili-dislike-qr-overlay">
<div class="bili-dislike-qr-overlay-icon"></div>
<span class="bili-dislike-qr-overlay-text"></span>
</div>
<div class="bili-dislike-qr-hover">
<span class="bili-dislike-qr-hover-text">刷新二维码</span>
</div>
</div>
<p class="bili-dislike-qr-status">请使用哔哩哔哩客户端扫描二维码</p>
<p class="bili-dislike-qr-tip">仅用于获取 App 端鉴权凭据</p>
</div>
`;
const closeBtn = dialog.querySelector(".bili-dislike-qr-close");
const wrapper = dialog.querySelector(".bili-dislike-qr-code-wrapper");
const overlay = dialog.querySelector(".bili-dislike-qr-overlay");
const overlayIcon = dialog.querySelector(".bili-dislike-qr-overlay-icon");
const overlayText = dialog.querySelector(".bili-dislike-qr-overlay-text");
const hoverText = dialog.querySelector(".bili-dislike-qr-hover-text");
const statusEl = dialog.querySelector(".bili-dislike-qr-status");
// Generate QR code using qrcodejs
const qrcode = new QRCode(dialog.querySelector(".bili-dislike-qr-code"), {
text: qrcodeUrl,
width: 184,
height: 184,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M,
});
const ICON_MAP = {
scanned: "✓",
success: "✓",
expired: "!",
error: "✕",
};
function close() {
if (dialog.dataset.closing !== undefined) return;
dialog.dataset.closing = "";
dialog.addEventListener(
"animationend",
() => {
dialog.close();
dialog.remove();
onClose?.();
},
{ once: true },
);
}
/**
* 更新弹窗状态
* @param {string} text 底部状态文字
* @param {string} [state] 状态类型: scanned | success | expired | error
* @param {string} [overlayLabel] 二维码遮罩文字(不传则隐藏遮罩)
*/
function setStatus(text, state, overlayLabel) {
statusEl.textContent = text;
statusEl.dataset.state = state ?? "";
if (state && overlayLabel) {
overlay.dataset.visible = "";
overlayIcon.textContent = ICON_MAP[state] ?? "";
overlayIcon.dataset.state = state;
overlayText.textContent = overlayLabel;
wrapper.dataset.hasIcon = "";
hoverText.textContent = "点击刷新二维码";
} else {
delete overlay.dataset.visible;
delete wrapper.dataset.hasIcon;
hoverText.textContent = "刷新二维码";
}
}
// Refresh QR code on wrapper click
wrapper.addEventListener("click", async () => {
const newUrl = await onRefresh?.();
if (newUrl) {
qrcode.makeCode(newUrl);
setStatus("请使用哔哩哔哩客户端扫描二维码");
}
});
// Close interactions
closeBtn.addEventListener("click", close);
let mousedownTarget = null;
dialog.addEventListener("mousedown", e => {
mousedownTarget = e.target;
});
dialog.addEventListener("click", e => {
if (e.target === dialog && mousedownTarget === dialog) close();
});
dialog.addEventListener("cancel", e => {
e.preventDefault();
close();
});
document.body.appendChild(dialog);
dialog.showModal();
return { dialog, setStatus, close };
}
const BilibiliAppAuth = createBilibiliAppAuth({ GM_xmlhttpRequest });
const bilibiliApp = new BilibiliAppAuth("tv");
let accessKey = GM_getValue("accessKey");
const loginMenuCommandId = GM_registerMenuCommand(
accessKey ? "[✓] 已登录 - 重新登录" : "[×] 未登录 - 扫码登录",
showLoginDialog,
);
function setAccessKey(value) {
GM_setValue("accessKey", value);
accessKey = value;
GM_registerMenuCommand(
accessKey ? "[✓] 已登录 - 重新登录" : "[×] 未登录 - 扫码登录",
showLoginDialog,
{
id: loginMenuCommandId,
},
);
}
/**
* 展示扫码登录弹窗
*/
async function showLoginDialog() {
const qrcodeUrl = await bilibiliApp.login();
if (!qrcodeUrl) return Promise.reject(new Error("获取二维码失败"));
return new Promise((resolve, reject) => {
let settled = false;
// 结算 Promise 并清理所有事件监听器,确保只执行一次
function settle() {
if (settled) return;
settled = true;
bilibiliApp.removeEventListener("scan", onScan);
bilibiliApp.removeEventListener("completed", onCompleted);
bilibiliApp.removeEventListener("error", onError);
}
const onScan = e => {
if (e.detail.code === 86090) {
qrDialog.setStatus("已扫码,请在客户端确认登录", "scanned", "已扫码");
}
};
const onCompleted = e => {
setAccessKey(e.detail.data.access_token);
qrDialog.setStatus("登录成功!", "success", "已登录");
setTimeout(() => qrDialog.close(), 500);
settle();
resolve(true);
};
const onError = e => {
if (e.detail.code === 86038) {
// 二维码过期:不 settle,用户可刷新重试,监听器保持活跃
qrDialog.setStatus("二维码已过期,请刷新重试", "expired", "已过期");
logger.warn("二维码已过期");
} else {
qrDialog.setStatus(`登录失败: ${e.detail.message}`, "error", "失败");
logger.error("登录失败:", e.detail);
settle();
reject(new Error("登录失败", { cause: e.detail }));
}
};
bilibiliApp.addEventListener("scan", onScan);
bilibiliApp.addEventListener("completed", onCompleted);
bilibiliApp.addEventListener("error", onError);
const qrDialog = createQrcodeDialog(qrcodeUrl, {
onClose: () => {
bilibiliApp.interrupt();
settle();
reject(new Error("登录取消", { cause: "canceled" }));
},
onRefresh: async () => {
bilibiliApp.interrupt();
return await bilibiliApp.login();
},
});
});
}
let disliked = false;
async function dislike(value = !disliked) {
if (!accessKey) {
toast("未登录,请先扫码登录");
return showLoginDialog().then(dislike.bind(null, value));
}
const { body } = await BilibiliAppAuth.request(
"https://app.biliapi.net/x/v2/view/dislike",
{
method: "POST",
data: bilibiliApp.signParams({
access_key: accessKey,
aid: unsafeWindow.__INITIAL_STATE__.aid,
dislike: value ? 0 : 1,
}),
responseType: "json",
anonymous: true,
},
);
switch (body.code) {
case 0:
return true;
case 65005:
case 65007:
return false;
case -101:
case -400:
default:
throw new Error(`失败: ${body.message}`, { cause: body });
}
}
async function queryIsDisliked() {
// 取消点踩成功,说明当前已点踩
const currDisliked = await dislike(false);
if (currDisliked) {
// 被取消了 还得踩回去
dislike(true);
}
return currDisliked;
}
window.addEventListener("load", async () => {
const likeBtnWrap = await waitForSelector(
document.body,
".video-toolbar-left-main>.toolbar-left-item-wrap:nth-of-type(1)",
);
const dislikeBtnWrap = likeBtnWrap.cloneNode(true);
const dislikeBtn = await waitForSelector(
dislikeBtnWrap,
".video-toolbar-left-item",
);
dislikeBtn.setAttribute("title", "不喜欢");
dislikeBtn.classList.replace("video-like", "video-dislike");
let dislikePending = false;
dislikeBtn.addEventListener("click", async () => {
if (dislikePending) return;
dislikePending = true;
try {
const prevDisliked = disliked;
const success = await dislike();
logger.log("点踩结果:", success);
if (success) {
disliked = !prevDisliked;
dislikeBtn.classList.toggle("on", disliked);
toast(disliked ? "感谢反馈" : "取消不喜欢");
} else {
toast("操作失败,请稍后重试");
}
} catch (err) {
if (err?.cause === "canceled") {
return;
}
logger.error("点踩失败:", err);
} finally {
dislikePending = false;
}
});
const dislikeBtnIcon = await waitForSelector(
dislikeBtn,
"svg.video-toolbar-item-icon",
);
dislikeBtnIcon.classList.replace("video-like-icon", "video-dislike-icon");
const dislikeBtnText = await waitForSelector(
dislikeBtn,
".video-toolbar-item-text",
);
dislikeBtnText.textContent = "不喜欢";
dislikeBtnText.classList.replace("video-like-info", "video-dislike-info");
await waitForVue2(likeBtnWrap.parentElement, { timeout: 3000 }).catch(
logger.error,
);
// 等待 Vue 组件完成挂载后再插入
likeBtnWrap.parentElement.insertBefore(
dislikeBtnWrap,
likeBtnWrap.nextSibling,
);
if (accessKey) {
disliked = await queryIsDisliked();
if (disliked) {
dislikeBtn.classList.add("on");
}
}
});
})();