Clean Bilibili

Cleanup moot UI widgets from bilibili.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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()
  }
})()