YouTube - Hide force-pushed low-view videos

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

// ==UserScript==
// @name         YouTube - Hide force-pushed low-view videos
// @namespace    https://github.com/BobbyWibowo
// @version      1.2.7
// @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: null, // this may only work for EN locale
    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',
      'ytd-rich-item-renderer:has(ytm-shorts-lockup-view-model-v2)',
      '#items > ytm-shorts-lockup-view-model-v2'
    ]
  };

  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*/`
    :is(${CONFIG.SELECTORS_ALLOWED_PAGE}) :is(${CONFIG.SELECTORS_VIDEO}) {
      transition: 0.25s opacity;
    }

    /* Visually hide, while still letting the element occupy the space.
     * To prevent YouTube from infinitely loading more videos. */
    :is(${CONFIG.SELECTORS_ALLOWED_PAGE}) :is(${CONFIG.SELECTORS_VIDEO}):not([data-noview_views], [data-noview_allowed_channel]) {
      visibility: hidden;
      opacity: 0;
    }

    [data-noview_threshold_unmet] {
      display: none !important;
    }
  `);

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

      /* Fix YouTube's home styling when some videos are hidden. */
      ytd-browse[page-subtype="home"] ytd-rich-item-renderer[rendered-from-rich-grid][is-in-first-column],
      ytd-browse[page-subtype="home"] #content.ytd-rich-section-renderer {
        margin-left: calc(var(--ytd-rich-grid-item-margin) / 2) !important;
      }

      ytd-browse[page-subtype="home"] #contents.ytd-rich-grid-renderer {
        padding-left: calc(var(--ytd-rich-grid-item-margin) / 2 + var(--ytd-rich-grid-gutter-margin)) !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];
    }
  }

  const isPartialElementInViewport = element => {
    if (element.style.display === 'none') {
      return false;
    }

    const rect = element.getBoundingClientRect();

    const windowHeight = window.innerHeight || document.documentElement.clientHeight;
    const windowWidth = window.innerWidth || document.documentElement.clientWidth;

    const vertInView = (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0);
    const horzInView = (rect.left <= windowWidth) && ((rect.left + rect.width) >= 0);

    return (vertInView && horzInView);
  };

  let intersectionObserver = null;

  let isPageAllowed = false;

  window.addEventListener('yt-navigate-start', event => {
    isPageAllowed = false;
    // Clear previous intersection observer.
    if (intersectionObserver !== null) {
      intersectionObserver.disconnect();
      intersectionObserver = null;
    }
  });

  window.addEventListener('yt-page-data-updated', event => {
    isPageAllowed = Boolean(document.querySelector(CONFIG.SELECTORS_ALLOWED_PAGE));
    if (isPageAllowed) {
      // Re-init intersection observer.
      intersectionObserver = new IntersectionObserver(entries => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            doVideo(entry.target);
            intersectionObserver.unobserve(entry.target);
          }
        }
      }, { delay: 100, threshold: 0 });
      logDebug('Page allowed, waiting for videos\u2026');
    } else {
      logDebug('Page not allowed.');
    }
  });

  /** MAIN **/

  const emptyMetadata = {
    channelIDs: null,
    author: null,
    isLive: null,
    isUpcoming: null,
    viewCount: null
  };

  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 structuredClone(emptyMetadata);
        }

        const channelIds = new Set();
        if (response?.videoDetails?.channelId) {
          channelIds.add(response?.videoDetails?.channelId);
        }

        // To get IDs of parent channel for auto-generated topic channels.
        const subscribeChannelIds = response?.playerConfig?.webPlayerConfig?.webPlayerActionsPorting?.subscribeCommand?.subscribeEndpoint?.channelIds;
        if (subscribeChannelIds?.length) {
          for (const id of subscribeChannelIds) {
            channelIds.add(id);
          }
        }

        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 {
          channelIDs: channelIds,
          author,
          isLive,
          isUpcoming,
          viewCount,
          playabilityStatus
        };
      }
    } catch (e) {}

    return structuredClone(emptyMetadata);
  };

  const videoMetadataCache = new DataCache(() => (structuredClone(emptyMetadata)));

  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.channelIDs = metadata.channelIDs;
          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.viewCount !== 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
        }, '*');

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

        if (metadata) {
          const videoCache = videoMetadataCache.setupCache(videoID);
          videoCache.channelIDs = metadata.channelIDs;
          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;
        }

        const _emptyMetadata = structuredClone(emptyMetadata);
        window.postMessage({
          type: 'youtube-noview:video-metadata-received',
          videoID,
          metadata: _emptyMetadata
        }, '*');
        return _emptyMetadata;
      })();

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

    return structuredClone(emptyMetadata);
  };

  const getVideoData = async element => {
    const videoLink = element.querySelector('a[href]');
    if (!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;
    }

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

    if (!videoID) {
      return null;
    }

    let channelId;
    if (CONFIG.ALLOWED_CHANNEL_IDS.length) {
      // Attempt to get channel ID early.
      if (element.tagName === 'YT-LOCKUP-VIEW-MODEL') {
        const symbols = Object.getOwnPropertySymbols(element.componentProps?.data ?? {});
        if (symbols.length) {
          const metadata = element.componentProps.data[symbols[0]].value?.metadata?.lockupMetadataViewModel;
          channelId = metadata?.image?.decoratedAvatarViewModel?.rendererContext?.commandContext?.onTap
            ?.innertubeCommand?.browseEndpoint?.browseId;
        }
      } else {
        const dismissible = element.querySelector('#dismissible');
        if (dismissible) {
          const data = dismissible.__dataHost?.__data?.data;
          channelId = data?.owner?.navigationEndpoint?.browseEndpoint?.browseId ||
            data?.longBylineText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId;
        }
      }
    }

    if (channelId && CONFIG.ALLOWED_CHANNEL_IDS.includes(channelId)) {
      logDebug('Skipped metadata fetch due to allowed channel', element);
      return { videoID, allowedChannel: channelId };
    }

    const metadata = await fetchVideoMetadata(videoID);
    return { videoID, metadata };
  };

  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"]'));
    }
  };

  function handleVideoUpdate () {
    const element = this;
    if (element.dataset.noview_threshold_unmet) {
      logDebug(`Resetting old statuses (${element.dataset.noview_views} < ${element.dataset.noview_threshold_unmet})`,
        element);
      delete element.dataset.noview_views;
      // Deleting this removes "display: none", which will trigger doVideo() via sentinel.
      delete element.dataset.noview_threshold_unmet;
    } else {
      delete element.dataset.noview_views;
      doVideo(element);
    }
  };

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

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

    if (CONFIG.ALLOWED_CHANNEL_IDS.length) {
      delete element.dataset.noview_allowed_channel;
      if (data.allowedChannel) {
        // Through early check via DOM properties.
        element.dataset.noview_channel_ids = JSON.stringify([data.allowedChannel]);
        element.dataset.noview_allowed_channel = true;
        return false;
      } else if (data.metadata.channelIDs?.size) {
        // Through metadata fetch from API.
        element.dataset.noview_channel_ids = JSON.stringify([...data.metadata.channelIDs]);
        if (CONFIG.ALLOWED_CHANNEL_IDS.some(id => data.metadata.channelIDs.has(id))) {
          element.dataset.noview_allowed_channel = true;
          return false;
        }
      }
    }

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

    const viewCount = parseInt(data.metadata.viewCount);
    let thresholdUnmet = null;

    if (CONFIG.VIEWS_THRESHOLD_LIVE !== null && data.metadata.isLive) {
      if (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 !== null &&
        (CONFIG.VIEWS_THRESHOLD_NEW !== CONFIG.VIEWS_THRESHOLD) &&
        isVideoNew(element);

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

    if (thresholdUnmet !== null) {
      log(`Hid video (${viewCount} <= ${thresholdUnmet})`, element);
      element.dataset.noview_threshold_unmet = thresholdUnmet;
    }

    element.dataset.noview_views = viewCount;
    return true;
  };

  /** SENTINEL */

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

    sentinel.on(CONFIG.SELECTORS_VIDEO, element => {
      if (!isPageAllowed) {
        return false;
      }

      if (isPartialElementInViewport(element)) {
        doVideo(element);
      } else {
        // If not in viewport, observe intersection.
        intersectionObserver.observe(element);
      }
    });
  });
})();
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

一年攒够 12 元

云驰互联

云驰互联