Clean Bilibili

Cleanup moot UI widgets from bilibili.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Clean Bilibili
// @namespace    https://ntnyq.com
// @version      0.0.8
// @description  Cleanup moot UI widgets from bilibili.
// @author       ntnyq (https://ntnyq.com)
// @license      MIT
// @homepageURL  https://github.com/ntnyq/clean-bilibili
// @supportURL   https://github.com/ntnyq/clean-bilibili
// @match        http*://*.bilibili.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=live.bilibili.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-body
// ==/UserScript==

// @ts-check

/**
 * @typedef Logger
 * @property {(...args: any[]) => void} log log
 * @property {(...args: any[]) => void} warn warn
 * @property {(...args: any[]) => void} error error
 * @property {(...args: any[]) => void} info info
 */

/**
 * @typedef {() => boolean} Condition
 */

;(function () {
  'use strict'

  const SCRIPT_NAME = 'clean_bilibili'

  /**
   * Create logger
   * @returns {Logger} logger instance
   */
  function createLogger() {
    const prefix = `[${SCRIPT_NAME}]`
    return {
      log: (...args) => console.log(prefix, ...args),
      warn: (...args) => console.warn(prefix, ...args),
      error: (...args) => console.error(prefix, ...args),
      info: (...args) => console.info(prefix, ...args),
    }
  }
  const logger = createLogger()

  /**
   * Wait for elements to be ready
   * @param {string} selector - selector to watch
   * @returns {Promise<Element[]>} elements
   */
  function waitForElements(selector) {
    return new Promise(resolve => {
      if (document.querySelectorAll(selector).length > 0) {
        resolve(Array.from(document.querySelectorAll(selector)))
      }

      const observer = new MutationObserver(() => {
        if (document.querySelectorAll(selector).length > 0) {
          resolve(Array.from(document.querySelectorAll(selector)))
          observer.disconnect()
        }
      })

      try {
        observer.observe(document.body, {
          childList: true,
          subtree: true,
        })
      } catch {
        // If failed, try again in 100ms
        setTimeout(() => {
          resolve(waitForElements(selector))
        }, 100)
      }
    })
  }

  /**
   * Wait until conditions are met
   *
   * @param {Condition | Condition[]} conditions - conditions
   * @param {() => void} callback - callback
   */
  function waitUntil(conditions, callback) {
    const observer = new MutationObserver(() => {
      const matchCondition = Array.isArray(conditions)
        ? conditions.every(condition => condition())
        : conditions()

      if (!matchCondition) return

      callback()
      observer.disconnect()
    })

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    })
  }

  /**
   * Create UI for the options
   * @param {string} key - option key
   * @param {string} title - option title
   * @param {boolean} defaultValue - option default-value
   * @returns {{ value: boolean }} value ref
   */
  function useOption(key, title, defaultValue) {
    if (typeof GM_getValue === 'undefined') {
      return {
        value: defaultValue,
      }
    }
    let value = GM_getValue(key, defaultValue)
    const ref = {
      get value() {
        return value
      },
      set value(v) {
        value = v
        GM_setValue(key, v)
        location.reload()
      },
    }
    GM_registerMenuCommand(`${title}: ${value ? '✅' : '❌'}`, () => {
      ref.value = !value
    })
    return ref
  }

  const doc = document

  // Up 主投稿 创作中心
  const HIDE_UP_ENTRY = useOption('bilibili_hide_up_entry', 'Hide UP Entry', true)
  // 话题 - 动态页面
  const HIDE_TOPIC_PANEL = useOption('bilibili_hide_topic_panel', 'Hide Topic Panel', true)
  // 显示关注时间
  const ENABLE_FOLLOW_TIME = useOption('bilibili_enable_follow_time', 'Enable Follow Time', true)

  /** @type {string[]} */
  const HIDE_SELECTORS = [
    /**
     * ================================
     *              全局
     * ================================
     */
    // 游戏中心
    '.bili-header .left-entry .default-entry[href*="game.bilibili.com"]',
    // 会员购
    '.bili-header .left-entry .default-entry[href*="show.bilibili.com"]',
    // 漫画
    '.bili-header .left-entry .default-entry[href*="manga.bilibili.com"]',
    // 赛事
    '.bili-header .left-entry .default-entry[href*="www.bilibili.com/match"]',
    // 年度报告
    '.bili-header .left-entry .loc-entry.loc-moveclip',
    // 番剧
    '.bili-header .left-entry .default-entry[href*="www.bilibili.com/bangumi/play"]',
    '.bili-header .left-entry .left-loc-entry a[href*="www.bilibili.com/bangumi/play"]',
    // 下载客户端
    '.bili-header .left-entry .download-entry[href*="app.bilibili.com"]',
    // 桌面端提示
    '.desktop-download-tip',

    /**
     * ================================
     *              个人动态
     * ================================
     */
    // 轮播广告
    '.bili-dyn-ads',

    /**
     * ================================
     *              视频页面
     * ================================
     */
    // 右侧轮播
    '.slide-ad-exp', // also #slide_ad
    // 游戏推荐
    '.video-page-game-card-small',
    // 评论区顶部推荐 右侧推荐区底部
    '.ad-report.ad-floor-exp',

    /**
     * ================================
     *              用户空间
     * ================================
     */
    // 最近玩儿过的游戏
    '.s-space .col-2 .section.game',

    /**
     * ================================
     *              直播页面
     * ================================
     */
    '#head-info-vm .activity-gather-entry',

    ...(HIDE_UP_ENTRY.value ? ['.bili-header .right-entry a[href*="member.bilibili.com"]'] : []),

    ...(HIDE_TOPIC_PANEL.value ? ['.sticky .bili-dyn-topic-box'] : []),
  ].filter(Boolean)

  /** @type {string[]} */
  const INJECTED_STYLE = [
    // 隐藏元素
    `${HIDE_SELECTORS.join(',')} { display: none !important;}`,
  ]

  async function onUserSpace() {
    if (!ENABLE_FOLLOW_TIME.value) {
      return logger.info('Follow time disabled')
    }

    if (/^https?:\/\/space\.bilibili\.com\/\d{3,}/.test(location.href)) {
      const getSpaceMid = () => location.pathname.split('/').at(1)
      const mid = getSpaceMid()

      if (!mid) return

      /**  @type any */
      let res = await unsafeWindow.fetch(
        `https://api.bilibili.com/x/space/acc/relation?mid=${mid}`,
        {
          credentials: 'include',
        },
      )
      res = await res.json()

      const mtime = /** @type {number?} */ (res?.data?.relation?.mtime)

      if (!mtime) return logger.error('Failed to get follow time')

      const result = new Date(mtime * 1000).toLocaleString()

      const panel = doc.querySelector('.s-space .col-2')

      if (!panel) return
      const section = doc.createElement('div')

      section.classList.add('section')
      section.innerHTML = `
        <span>关注时间:</span>
        <strong>${result}</strong>
      `

      panel.prepend(section)
    }
  }

  async function hideShadowDOM() {
    const styleSheet = new CSSStyleSheet()
    styleSheet.replaceSync('#notice { display: none !important; }')
    document
      .querySelector('#commentapp bili-comments')
      ?.shadowRoot?.querySelector('bili-comments-header-renderer')
      ?.shadowRoot?.adoptedStyleSheets.push(styleSheet)
  }

  async function registerSevices() {
    waitForElements('#page-dynamic').then(onUserSpace)
    waitUntil(
      () => !!document.querySelector('#commentapp bili-comments'),
      () => {
        hideShadowDOM()
      },
    )
  }

  /**
   * Inject style
   */
  function injectStyle() {
    const el = doc.createElement('style')

    el.id = `${SCRIPT_NAME}_style`
    el.innerHTML = INJECTED_STYLE.join('')
    doc.head.append(el)

    logger.info('Style injected')
  }

  if (doc.readyState === 'loading') {
    doc.addEventListener('DOMContentLoaded', () => {
      injectStyle()
      registerSevices()
    })
  } else {
    injectStyle()
    registerSevices()
  }
})()