自用bilibili脚本

吧啦吧啦

// ==UserScript==
// @name         自用bilibili脚本
// @namespace    mimiko/bilibili
// @version      0.0.28
// @description  吧啦吧啦
// @author       Mimiko
// @license      MIT
// @match        *://*.bilibili.com/*
// @grant        GM.addStyle
// @grant        GM.xmlHttpRequest
// @run-at       document-end
// ==/UserScript==
// https://greasyfork.org/zh-CN/scripts/436748-%E8%87%AA%E7%94%A8bilibili%E8%84%9A%E6%9C%AC
"use strict";
(() => {
  if (window.top !== window.self) return;
  const Utils = {
    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: (...selectors) => {
      Utils.addStyle`
      ${selectors.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;
      Utils.save("list-blacklist", []);
      Utils.save("time-blacklist", 0);
    },
    has: (name) => Blacklist.list.includes(name),
    load: async () => {
      if (!User.isLogin) return;
      const time = Utils.load("time-blacklist");
      if (!time || Date.now() - time > 864e5) {
        await Blacklist.update();
        return;
      }
      Blacklist.list = Utils.load("list-blacklist") ?? [];
    },
    save: () => {
      if (!User.isLogin) return;
      Utils.save("list-blacklist", Blacklist.list);
      Utils.save("time-blacklist", Date.now());
    },
    update: async () => {
      const PAGE_SIZE = 50;
      const fetchPage = async (pageNum = 0) => {
        if (!User.isLogin) return [];
        const result = await Utils.get(
          "https://api.bilibili.com/x/relation/blacks",
          {
            pn: pageNum,
            ps: PAGE_SIZE,
          },
        );
        if (!result?.list.length) return [];
        const usernames = result.list.map((user) => user.uname);
        if (usernames.length >= PAGE_SIZE)
          return [...usernames, ...(await fetchPage(pageNum + 1))];
        return usernames;
      };
      Blacklist.list = await fetchPage();
      Blacklist.save();
    },
  };
  // LRU cache for video views
  const Cache = {
    LIMIT: 5e4,
    countMap: new Map(),
    accessTimeMap: new Map(),
    init: () => {
      const savedData = Utils.load("cache-recommend") ?? [];
      Cache.countMap.clear();
      Cache.accessTimeMap.clear();
      let baseTime = Date.now() - savedData.length;
      for (const [id, count] of savedData) {
        Cache.countMap.set(id, count);
        Cache.accessTimeMap.set(id, baseTime++);
      }
    },
    add: (id) => {
      const currentCount = Cache.countMap.get(id) ?? 0;
      Cache.countMap.set(id, currentCount + 1);
      Cache.accessTimeMap.set(id, Date.now());
      if (Cache.countMap.size > Cache.LIMIT) {
        let oldestId = "";
        let oldestTime = Infinity;
        for (const [videoId, accessTime] of Cache.accessTimeMap) {
          if (accessTime < oldestTime) {
            oldestTime = accessTime;
            oldestId = videoId;
          }
        }
        if (oldestId) {
          Cache.countMap.delete(oldestId);
          Cache.accessTimeMap.delete(oldestId);
        }
      }
    },
    clear: () => {
      Cache.countMap.clear();
      Cache.accessTimeMap.clear();
      Cache.save();
    },
    get: (id) => [id, Cache.countMap.get(id) ?? 0],
    save: () => {
      const sortedEntries = Array.from(Cache.countMap.entries()).sort(
        (a, b) => {
          const timeA = Cache.accessTimeMap.get(a[0]) ?? 0;
          const timeB = Cache.accessTimeMap.get(b[0]) ?? 0;
          return timeA - timeB;
        },
      );
      Utils.save("cache-recommend", sortedEntries);
    },
  };
  const Router = {
    count: {
      index: 0,
      unknown: 0,
      video: 0,
    },
    handlers: {
      index: [],
      unknown: [],
      video: [],
    },
    currentPath: "",
    init: () => {
      const checkRoute = async () => {
        const { pathname } = window.location;
        if (pathname === Router.currentPath) return;
        Router.currentPath = pathname;
        const currentPage = Utils.run(() => {
          if (Router.is("index")) return "index";
          if (Router.is("video")) return "video";
          return "unknown";
        });
        if (currentPage === "unknown") return;
        const pageHandlers = Router.handlers[currentPage];
        for (const handler of pageHandlers) {
          if (Router.count[currentPage] === 0)
            handler.initResult = await handler.init();
          if (handler.onRoute && typeof handler.onRoute === "function")
            handler.onRoute();
          if (typeof handler.initResult === "function")
            handler.onRoute = await handler.initResult();
        }
        Router.count[currentPage]++;
      };
      window.setInterval(checkRoute, 200);
      return Router;
    },
    is: (pageName) => {
      const { pathname } = window.location;
      if (pageName === "index") return ["/", "/index.html"].includes(pathname);
      if (pageName === "video") return pathname.startsWith("/video/");
      return true;
    },
    watch: (pageName, initFn) => {
      Router.handlers[pageName].push({
        init: initFn,
        initResult: undefined,
        onRoute: null,
      });
      return Router;
    },
  };
  const User = {
    isLogin: false,
    update: async () => {
      const result = await Utils.get(
        "https://api.bilibili.com/x/web-interface/nav",
      );
      if (!result) return;
      User.isLogin = result.isLogin;
    },
  };
  const setupIndex = async () => {
    Utils.addStyle`
    body { min-width: auto; }
    .container:first-child { display: none; }

    .feed-roll-btn button:first-of-type { height: ${Utils.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; }
    `;
    const [container] = await Utils.getElements(".container");
    const newContainer = document.createElement("div");
    container.classList.forEach((className) =>
      newContainer.classList.add(className),
    );
    container.parentNode?.append(newContainer);
    const [buttonGroup] = await Utils.getElements(".feed-roll-btn");
    const clearButton = document.createElement("button");
    clearButton.classList.add("primary-btn", "roll-btn");
    clearButton.innerHTML = "<span>✖</span>";
    clearButton.setAttribute("title", "清空缓存");
    clearButton.addEventListener("click", () => {
      Blacklist.clear();
      Cache.clear();
      alert("缓存已清空");
    });
    clearButton.style.marginTop = "20px";
    buttonGroup.append(clearButton);
    return async () => {
      await User.update();
      await Blacklist.load();
      const handleMutation = () => {
        observer.disconnect();
        const feedItems = [...container.children].filter((item) =>
          item.classList.contains("feed-card"),
        );
        const itemsToShow = [];
        const itemsToHide = [];
        feedItems.forEach((item) => {
          const checkItem = () => {
            const authorElement = item.querySelector(
              ".bili-video-card__info--author",
            );
            const author = authorElement?.textContent ?? "";
            if (!author)
              return {
                hideReason: "UP主不存在",
                viewCount: 0,
              };
            const linkElement = item.querySelector("a");
            if (!linkElement)
              return {
                hideReason: "链接不存在",
                viewCount: 0,
              };
            const statsElement = item.querySelector(
              ".bili-video-card__stats--text",
            );
            const statsText = statsElement?.textContent ?? "";
            if (!statsText)
              return {
                hideReason: "播放量不存在",
                viewCount: 0,
              };
            const viewCount = Utils.run(() => {
              if (statsText.includes("万"))
                return parseFloat(statsText.split("万")[0]) * 1e4;
              const match = statsText.match(/(\d+(?:\.\d+)?)/);
              return match ? parseFloat(match[1]) : 0;
            });
            if (Blacklist.has(author))
              return {
                hideReason: "UP主在黑名单中",
                viewCount,
              };
            if (!statsText.includes("万"))
              return {
                hideReason: "播放量不足1万",
                viewCount,
              };
            if (viewCount < 3e4)
              return {
                hideReason: "播放量不足3万",
                viewCount,
              };
            const infoElement = item.querySelector(
              ".bili-video-card__info--icon-text",
            );
            const videoId = linkElement.href
              .replace(/.*\/BV/, "")
              .replace(/\/\?.*/, "");
            const threshold = infoElement?.textContent === "已关注" ? 2 : 0;
            if (Cache.get(videoId)[1] > threshold) {
              return {
                hideReason: `已出现${Cache.get(videoId)[1]}次`,
                viewCount,
              };
            }
            Cache.add(videoId);
            return {
              hideReason: "",
              viewCount,
            };
          };
          const { hideReason, viewCount } = checkItem();
          const itemData = {
            element: item,
            viewCount,
          };
          if (!hideReason) {
            itemsToShow.push(itemData);
            return;
          }
          if (hideReason.includes("不存在")) return;
          item.classList.add("is-hidden");
          const reasonTip = document.createElement("div");
          reasonTip.classList.add("reason");
          reasonTip.textContent = hideReason;
          item.append(reasonTip);
          itemsToHide.push(itemData);
        });
        itemsToShow.sort((a, b) => b.viewCount - a.viewCount);
        itemsToHide.sort((a, b) => b.viewCount - a.viewCount);
        const fragment = document.createDocumentFragment();
        itemsToShow.forEach(({ element }) => {
          fragment.appendChild(element);
        });
        itemsToHide.forEach(({ element }) => {
          fragment.appendChild(element);
        });
        newContainer.appendChild(fragment);
        Cache.save();
        observer.observe(container, {
          childList: true,
        });
      };
      const observer = new MutationObserver(handleMutation);
      handleMutation();
      return () => observer.disconnect();
    };
  };
  const setupVideo = () => {
    Utils.addStyle`
    .video-share-popover { display: none; }
    `;
    const toggleFullscreen = async () => {
      const [button] = await Utils.getElements(".bpx-player-ctrl-web");
      button.click();
    };
    const toggleWidescreen = async () => {
      const [button] = await Utils.getElements(".bpx-player-ctrl-wide");
      button.click();
    };
    const handleKeydown = (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") {
        toggleFullscreen();
        return;
      }
      if (e.key === "w") toggleWidescreen();
    };
    return () => {
      document.addEventListener("keydown", handleKeydown);
      return () => {
        document.removeEventListener("keydown", handleKeydown);
      };
    };
  };
  Utils.run(() => {
    if (window.location.hostname !== "www.bilibili.com") return;
    Cache.init();
    Router.watch("index", setupIndex).watch("video", setupVideo).init();
  });
})();