Bilibili Dislike

为哔哩哔哩视频页面添加不喜欢(点踩)按钮

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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