YouTube - Hide force-pushed low-view videos

Hide videos matching thresholds, in home page, and watch page's sidebar. CONFIGURABLE!

当前为 2025-07-04 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube - Hide force-pushed low-view videos
// @namespace    https://github.com/BobbyWibowo
// @version      1.2.0
// @description  Hide videos matching thresholds, in home page, and watch page's sidebar. CONFIGURABLE!
// @author       Bobby Wibowo
// @license      MIT
// @match        *://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @run-at       document-start
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/sentinel.min.js
// @noframes
// ==/UserScript==

/* global sentinel */

(function () {
  'use strict';

  const _LOG_TIME_FORMAT = new Intl.DateTimeFormat('en-GB', {
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    fractionalSecondDigits: 3
  });

  const log = (message, ...args) => {
    const prefix = `[${_LOG_TIME_FORMAT.format(Date.now())}]: `;
    if (typeof message === 'string') {
      return console.log(prefix + message, ...args);
    } else {
      return console.log(prefix, message, ...args);
    }
  };

  /** CONFIG **/

  /* It's recommended to edit these values through your userscript manager's storage/values editor.
   * Visit YouTube once after installing the script to allow it to populate its storage with default values.
   * Especially necessary for Tampermonkey to show the script's Storage tab when Advanced mode is turned on.
   */
  const ENV_DEFAULTS = {
    MODE: 'PROD',

    VIEWS_THRESHOLD: 999,
    VIEWS_THRESHOLD_NEW: 499,
    VIEWS_THRESHOLD_LIVE: null, // based on the livestream's accumulative views count reported by YouTube API

    ALLOWED_CHANNEL_IDS: [],

    DISABLE_STYLES: false,

    SELECTORS_ALLOWED_PAGE: null,
    SELECTORS_VIDEO: null
  };

  /* Hard-coded preset values.
   * Specifying custom values will extend instead of replacing them.
   */
  const PRESETS = {
    // To ensure any custom values will be inserted into array, or combined together if also an array.
    ALLOWED_CHANNEL_IDS: [],

    // Keys that starts with "SELECTORS_", and in array, will automatically be converted to single-line strings.
    SELECTORS_ALLOWED_PAGE: [
      'ytd-browse[page-subtype="home"]:not([hidden])', // home
      'ytd-watch-flexy:not([hidden])' // watch page
    ],
    SELECTORS_VIDEO: [
      'ytd-compact-video-renderer:has(#dimissible)',
      'ytd-rich-item-renderer:has(#dismissible)',
      'yt-lockup-view-model',
      'ytm-shorts-lockup-view-model'
    ]
  };

  const ENV = {};

  // Store default values.
  for (const key of Object.keys(ENV_DEFAULTS)) {
    const stored = GM_getValue(key);
    if (stored === null || stored === undefined) {
      ENV[key] = ENV_DEFAULTS[key];
      GM_setValue(key, ENV_DEFAULTS[key]);
    } else {
      ENV[key] = stored;
    }
  }

  const _DOCUMENT_FRAGMENT = document.createDocumentFragment();
  const queryCheck = selector => _DOCUMENT_FRAGMENT.querySelector(selector);

  const isSelectorValid = selector => {
    try {
      queryCheck(selector);
    } catch {
      return false;
    }
    return true;
  };

  const CONFIG = {};

  // Extend hard-coded preset values with user-defined custom values, if applicable.
  for (const key of Object.keys(ENV)) {
    if (key.startsWith('SELECTORS_')) {
      if (Array.isArray(PRESETS[key])) {
        CONFIG[key] = PRESETS[key].join(', ');
      } else {
        CONFIG[key] = PRESETS[key] || '';
      }
      if (ENV[key]) {
        CONFIG[key] += `, ${Array.isArray(ENV[key]) ? ENV[key].join(', ') : ENV[key]}`;
      }
      if (!isSelectorValid(CONFIG[key])) {
        console.error(`${key} contains invalid selector =`, CONFIG[key]);
        return;
      }
    } else if (Array.isArray(PRESETS[key])) {
      CONFIG[key] = PRESETS[key];
      if (ENV[key]) {
        const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
        CONFIG[key].push(...customValues);
      }
    } else {
      CONFIG[key] = PRESETS[key] || null;
      if (ENV[key] !== null) {
        CONFIG[key] = ENV[key];
      }
    }
  }

  let logDebug = () => {};
  if (CONFIG.MODE !== 'PROD') {
    logDebug = log;
    for (const key of Object.keys(CONFIG)) {
      logDebug(`${key} =`, CONFIG[key]);
    }
  }

  /** STYLES **/

  // Styling that must always be enabled for the script's core functionalities.
  GM_addStyle(/*css*/`
    [data-noview_threshold_unmet] {
      display: none !important;
    }

    /* Visually hide, while still letting the element occupy the space.
     * To prevent YouTube from infinitely loading more videos. */
    [data-noview_processing] {
      visibility: none !important;
    }
  `);

  if (!CONFIG.DISABLE_STYLES) {
    GM_addStyle(/*css*/`
      [data-noview_allowed_channel] #metadata-line span:nth-last-child(2 of .inline-metadata-item) {
        font-style: italic !important;
      }
    `);
  }

  /** UTILS **/

  const waitPageLoaded = () => {
    return new Promise(resolve => {
      if (document.readyState === 'complete' ||
        document.readyState === 'loaded' ||
        document.readyState === 'interactive') {
        resolve();
      } else {
        document.addEventListener('DOMContentLoaded', resolve);
      }
    });
  };

  class DataCache {
    cache;
    init;
    cacheLimit;

    constructor (init, cacheLimit = 2000) {
      this.cache = {};
      this.init = init;
      this.cacheLimit = cacheLimit;
    }

    getFromCache (key) {
      return this.cache[key];
    }

    setupCache (key) {
      if (!this.cache[key]) {
        this.cache[key] = {
          ...this.init(),
          lastUsed: Date.now()
        };

        if (Object.keys(this.cache).length > this.cacheLimit) {
          const oldest = Object.entries(this.cache).reduce((a, b) => a[1].lastUsed < b[1].lastUsed ? a : b);
          delete this.cache[oldest[0]];
        }
      }

      return this.cache[key];
    }

    cacheUsed (key) {
      if (this.cache[key]) this.cache[key].lastUsed = Date.now();

      return !!this.cache[key];
    }
  }

  let isPageAllowed = false;

  window.addEventListener('yt-navigate-start', event => {
    isPageAllowed = false;
  });

  window.addEventListener('yt-page-data-updated', event => {
    isPageAllowed = Boolean(document.querySelector(CONFIG.SELECTORS_ALLOWED_PAGE));
    if (isPageAllowed) {
      logDebug('Page allowed, waiting for videos\u2026');
    } else {
      logDebug('Page not allowed.');
    }
  });

  /** MAIN **/

  const fetchVideoDataDesktopClient = async videoID => {
    const url = 'https://www.youtube.com/youtubei/v1/player';
    const data = {
      context: {
        client: {
          clientName: 'WEB',
          clientVersion: '2.20230327.07.00'
        }
      },
      videoId: videoID
    };

    try {
      const result = await fetch(url, {
        body: JSON.stringify(data),
        headers: {
          'Content-Type': 'application/json'
        },
        method: 'POST'
      });

      if (result.ok) {
        const response = await result.json();
        const newVideoID = response?.videoDetails?.videoId ?? null;
        if (newVideoID !== videoID) {
          return {
            channelID: null,
            author: null,
            isLive: null,
            isUpcoming: null,
            viewCount: null
          };
        }

        const channelId = response?.videoDetails?.channelId ?? null;
        const author = response?.videoDetails?.author ?? null;
        const isLive = response?.videoDetails?.isLive ?? null;
        const isUpcoming = response?.videoDetails?.isUpcoming ?? null;
        const viewCount = response?.videoDetails?.viewCount ?? null;
        const playabilityStatus = response?.playabilityStatus?.status ?? null;

        return {
          channelID: channelId,
          author,
          isLive,
          isUpcoming,
          viewCount,
          playabilityStatus
        };
      }
    } catch (e) {}

    return {
      channelID: null,
      author: null,
      isLive: null,
      isUpcoming: null,
      viewCount: null
    };
  };

  const videoMetadataCache = new DataCache(() => ({
    channelID: null,
    author: null,
    isLive: null,
    isUpcoming: null,
    viewCount: null
  }));

  const waitingForMetadata = [];

  function setupMetadataOnRecieve () {
    const onMessage = event => {
      if (event.data?.type === 'youtube-noview:video-metadata-received') {
        const data = event.data;
        if (data.videoID && data.metadata && !videoMetadataCache.getFromCache(data.videoID)) {
          const metadata = data.metadata;
          const cachedData = videoMetadataCache.setupCache(data.videoID);

          cachedData.channelID = metadata.channelID;
          cachedData.author = metadata.author;
          cachedData.isLive = metadata.isLive;
          cachedData.isUpcoming = metadata.isUpcoming;
          cachedData.viewCount = metadata.viewCount;

          const index = waitingForMetadata.findIndex((item) => item.videoID === data.videoID);
          if (index !== -1) {
            waitingForMetadata[index].callbacks.forEach((callback) => {
              callback(data.metadata);
            });

            waitingForMetadata.splice(index, 1);
          }
        }
      } else if (event.data?.type === 'youtube-noview:video-metadata-requested' &&
        !(event.data.videoID in activeRequests)) {
        waitingForMetadata.push({
          videoID: event.data.videoID,
          callbacks: []
        });
      }
    };

    window.addEventListener('message', onMessage);
  }

  const activeRequests = {};

  const fetchVideoMetadata = async videoID => {
    const cachedData = videoMetadataCache.getFromCache(videoID);
    if (cachedData && cachedData.channelID !== null) {
      return cachedData;
    }

    let waiting = waitingForMetadata.find(item => item.videoID === videoID);
    if (waiting) {
      return new Promise((resolve) => {
        if (!waiting) {
          waiting = {
            videoID,
            callbacks: []
          };

          waitingForMetadata.push(waiting);
        }

        waiting.callbacks.push(metadata => {
          videoMetadataCache.cacheUsed(videoID);
          resolve(metadata);
        });
      });
    }

    try {
      const result = activeRequests[videoID] ?? (async () => {
        window.postMessage({
          type: 'maze-utils:video-metadata-requested',
          videoID
        }, '*');

        let metadata = await fetchVideoDataDesktopClient(videoID).catch(() => null);

        // Don't retry for LOGIN_REQUIRED, they will never have urls
        if (!metadata || metadata.playabilityStatus !== 'LOGIN_REQUIRED') {
          metadata = await fetchVideoDataDesktopClient(videoID).catch(() => null);
        }

        if (metadata) {
          const videoCache = videoMetadataCache.setupCache(videoID);
          videoCache.channelID = metadata.channelID;
          videoCache.author = metadata.author;
          videoCache.isLive = metadata.isLive;
          videoCache.isUpcoming = metadata.isUpcoming;
          videoCache.viewCount = metadata.viewCount;

          // Remove this from active requests after it's been dealt with in other places
          setTimeout(() => delete activeRequests[videoID], 500);

          window.postMessage({
            type: 'youtube-noview:video-metadata-received',
            videoID,
            metadata: videoCache
          }, '*');

          return videoCache;
        }

        window.postMessage({
          type: 'youtube-noview:video-metadata-received',
          videoID,
          metadata: {
            channelID: null,
            author: null,
            isLive: null,
            isUpcoming: null,
            viewCount: null
          }
        }, '*');

        return {
          channelID: null,
          author: null,
          isLive: null,
          isUpcoming: null,
          viewCount: null
        };
      })();

      activeRequests[videoID] = result;
      return await result;
    } catch (e) { }

    return {
      channelID: null,
      author: null,
      isLive: null,
      isUpcoming: null,
      viewCount: null
    };
  };

  const getVideoData = async element => {
    const videoLink = element.querySelector('a[href]');
    if (!videoLink || !videoLink.href) {
      return null;
    }

    const url = videoLink.href;

    let urlObject;
    try {
      urlObject = new URL(url);
    } catch (error) {
      log('Unable to parse URL:', url);
      return null;
    }

    if (urlObject.searchParams.has('v') && ['/watch', '/watch/'].includes(urlObject.pathname)) {
      const videoID = urlObject.searchParams.get('v');
      const metadata = await fetchVideoMetadata(videoID);
      return { videoID, metadata };
    } else if (urlObject.pathname.match(/^\/embed\/|^\/shorts\/|^\/live\//)) {
      try {
        const id = urlObject.pathname.split('/')[2];
        if (id?.length >= 11) {
          const videoID = id.slice(0, 11);
          const metadata = await fetchVideoMetadata(videoID);
          return { videoID, metadata };
        }
      } catch (e) {
        log('Video ID not valid for:', url);
        return null;
      }
    }
  };

  const isVideoNew = element => {
    if (element.tagName === 'YT-LOCKUP-VIEW-MODEL') {
      const badges = Array.from(element.querySelectorAll('yt-content-metadata-view-model .badge-shape-wiz__text'));
      return badges.some(badge => badge?.innerText === 'New');
    } else {
      return Boolean(element.querySelector('#dismissible .badge[aria-label="New"]'));
    }
  };

  const handleVideoUpdate = element => {
    if (element.dataset.noview_threshold_unmet) {
      logDebug(`Resetting old statuses (${element.dataset.noview_views} <= ${element.dataset.noview_threshold_unmet})`,
        element);
      delete element.dataset.noview_threshold_unmet;
      delete element.dataset.noview_views;
    }
  };

  const doVideo = async element => {
    if (!isPageAllowed) {
      return false;
    }

    // Mark video as processing (immediately hide).
    element.dataset.noview_processing = true;

    // Listen to this event to handle dynamic update (during page navigation).
    element.addEventListener('yt-enable-lockup-interaction', () => handleVideoUpdate(element));

    const data = await getVideoData(element);
    if (!data) {
      return false;
    }

    if (CONFIG.ALLOWED_CHANNEL_IDS.length) {
      delete element.dataset.noview_allowed_channel;
      if (data.metadata.channelID !== null) {
        if (CONFIG.ALLOWED_CHANNEL_IDS.includes(data.metadata.channelID)) {
          logDebug(`Ignoring video from an allowed channel (${data.metadata.channelID})`, element);
          element.dataset.noview_allowed_channel = data.metadata.channelID;
          return false;
        }
      }
    }

    if (data.metadata.viewCount === null) {
      logDebug('Unable to access views data', element, data);
      return false;
    }

    let thresholdUnmet = null;

    if (data.metadata.isLive && CONFIG.VIEWS_THRESHOLD_LIVE !== null) {
      if (data.metadata.viewCount <= CONFIG.VIEWS_THRESHOLD_LIVE) {
        thresholdUnmet = CONFIG.VIEWS_THRESHOLD_LIVE;
      }
    } else {
      // Do not look for New badge if thresholds are identical.
      const isNew = (CONFIG.VIEWS_THRESHOLD_NEW !== CONFIG.VIEWS_THRESHOLD) && isVideoNew(element);

      if (isNew) {
        if (data.metadata.viewCount <= CONFIG.VIEWS_THRESHOLD_NEW) {
          thresholdUnmet = CONFIG.VIEWS_THRESHOLD_NEW;
        }
      } else {
        if (data.metadata.viewCount <= CONFIG.VIEWS_THRESHOLD) {
          thresholdUnmet = CONFIG.VIEWS_THRESHOLD;
        }
      }
    }

    if (thresholdUnmet === null) {
      return false;
    }

    log(`Hid video (${data.metadata.viewCount} <= ${thresholdUnmet})`, element);
    element.dataset.noview_threshold_unmet = thresholdUnmet;
    element.dataset.noview_views = data.metadata.viewCount;

    return true;
  };

  /** SENTINEL */

  waitPageLoaded().then(() => {
    setupMetadataOnRecieve();

    sentinel.on(CONFIG.SELECTORS_VIDEO, async element => {
      await doVideo(element).catch(() => {});
      // Mark video as done processing (unhide).
      delete element.dataset.noview_processing;
    });
  });
})();