Clean Bilibili

Cleanup moot UI widgets from bilibili.

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