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