YouTube Repeated Recommendations Hider

Hide any videos that are recommended more than twice. You can also hide by channel or by partial title. Works on both YouTube's desktop and mobile layouts.

当前为 2022-01-09 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name YouTube Repeated Recommendations Hider
  3. // @description Hide any videos that are recommended more than twice. You can also hide by channel or by partial title. Works on both YouTube's desktop and mobile layouts.
  4. // @version 2.0.0
  5. // @author BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
  6. // @copyright 2020+, BLBC (github.com/hjk789, greasyfork.org/users/679182-hjk789)
  7. // @homepage https://github.com/hjk789/Userscripts/tree/master/YouTube-Repeated-Recommendations-Hider
  8. // @license https://github.com/hjk789/Userscripts/tree/master/YouTube-Repeated-Recommendations-Hider#license
  9. // @match https://m.youtube.com/*
  10. // @match https://www.youtube.com/*
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @grant GM.listValues
  14. // @grant GM.deleteValue
  15. // @namespace https://greasyfork.org/users/679182
  16. // ==/UserScript==
  17.  
  18.  
  19. //********** SETTINGS ***********
  20.  
  21. const maxRepetitions = 2 // The maximum number of times that the same recommended video is allowed to appear
  22. // before starting to get hidden. Set this to 1 if you want one-time recommendations.
  23.  
  24. const filterRelated = true // Whether the related videos (the ones below/beside the video you are watching) should also be filtered. Set this to false if you want to keep them untouched.
  25.  
  26. const countRelated = true // When false, new related videos are ignored in the countings and are allowed to appear any number of times, as long as they don't
  27. // appear in the homepage recommendations. If set to true, the related videos are counted even if they never appeared in the homepage.
  28.  
  29. const filterPremiere = false // Whether to include in the filtering repeated videos yet to be premiered. If set to false, these recommendations won't get "remembered"
  30. // until the video is finally released, then it will start counting as any other video. Set this to true if you want to hide them anyway.
  31.  
  32. const filterLives = true // Same as above but for ongoing live streams. If set to false, the recommended live stream will start to be counted only after the stream ends and becomes a whole video.
  33.  
  34. const dimFilteredHomepage = false // Whether the repeated recommendations in the homepage should get dimmed (partially faded) instead of completely hidden.
  35. const dimFilteredRelated = true // Same thing, but for the related videos.
  36.  
  37. const dimWatchedVideos = true // Whether the title of videos already watched should be dimmed, to differentiate from the ones you didn't watched yet. The browser itself is responsible for checking whether the
  38. // link was already visited or not, so if you delete a video from the browser history it will be treated as "not watched", the same if you watch them in a private window (incognito).
  39.  
  40. //*******************************
  41.  
  42.  
  43.  
  44. let channelsToHide, partialTitlesToHide, selectedChannel
  45. let hideChannelButton, hidePartialTitleButton
  46. let processedVideosList, isHomepage
  47. let currentPage = location.href, url
  48. const isMobile = !/www/.test(location.hostname)
  49.  
  50.  
  51. const waitForURLchange = setInterval(function() // Because YouTube is a single-page web app, everything happens in the same page, only changing the URL.
  52. { // So the script needs to check when the URL changes so it can be reassigned to the page and be able to work.
  53. url = location.href.split("#")[0] // In the mobile layout, when a menu is open, a hash is added to the URL. This hash need to be ignored to prevent incorrect detections.
  54.  
  55. if (url != currentPage)
  56. {
  57. currentPage = url
  58.  
  59. if (location.pathname != "/" && location.pathname != "/watch") // Only run on the homepage and video page.
  60. return
  61.  
  62. main()
  63. }
  64.  
  65. }, 500)
  66.  
  67.  
  68. const onViewObserver = new IntersectionObserver((entries) => // An intersection observer is being used so that the recommendations are counted only
  69. { // when the user actually sees them on the screen, instead of when they are loaded.
  70. entries.forEach(entry =>
  71. {
  72. if (entry.isIntersecting)
  73. processRecommendation(entry.target, isHomepage)
  74. })
  75. }, {threshold: 1.0}) // Only trigger the observer when the recommendation is completely visible.
  76.  
  77.  
  78. if (dimWatchedVideos)
  79. {
  80. // Add the style for dimming watched videos
  81. const style = document.createElement("style")
  82. style.innerHTML = ":visited, :visited h3, :visited #video-title { color: #aaa !important; }"
  83. document.head.appendChild(style)
  84. }
  85.  
  86. /* Create the desktop version's hover styles for the recommendation menu items */
  87. if (!isMobile)
  88. {
  89. const style = document.createElement("style")
  90. style.innerHTML = "#hideChannelButton:hover, #hidePartialTitleButton:hover { background-color: #e7e7e7 !important; }"
  91. document.head.appendChild(style)
  92. }
  93.  
  94.  
  95. getGMsettings()
  96.  
  97.  
  98.  
  99. async function getGMsettings()
  100. {
  101. let value = await GM.getValue("channels")
  102.  
  103. if (!value)
  104. {
  105. value = "[]"
  106. GM.setValue("channels", value)
  107. GM.setValue("partialTitles", value)
  108. }
  109.  
  110. channelsToHide = JSON.parse(value)
  111.  
  112. value = await GM.getValue("partialTitles")
  113.  
  114. partialTitlesToHide = JSON.parse(value)
  115.  
  116. processedVideosList = await GM.listValues() // Get in an array all the items currently in the script's storage. Searching for a value in
  117. // an array is much faster and lighter than calling GM.getValue for every recommendation.
  118.  
  119. main()
  120. }
  121.  
  122.  
  123. function main()
  124. {
  125. isHomepage = location.pathname == "/"
  126.  
  127. if (isHomepage)
  128. {
  129. const waitForRecommendationsContainer = setInterval(function()
  130. {
  131. let recommendationsContainer
  132.  
  133. if (isMobile)
  134. {
  135. recommendationsContainer = document.querySelector(".rich-grid-renderer-contents")
  136.  
  137. try { recommendationsContainer.children[0].querySelector("h3, h4").nextSibling.firstChild.firstChild.textContent } // In some very specific cases an error may occur while getting the video title because it's not available yet. This try-catch
  138. catch(e) { return } // is a straight-forward way of making sure that all elements of the path are available, instead of checking each one.
  139. }
  140. else
  141. {
  142. recommendationsContainer = document.querySelector("#contents ytd-rich-grid-row")
  143.  
  144. if (recommendationsContainer)
  145. recommendationsContainer = recommendationsContainer.parentElement
  146. }
  147.  
  148. if (!recommendationsContainer)
  149. return
  150.  
  151. clearInterval(waitForRecommendationsContainer)
  152.  
  153.  
  154. recommendationsContainer.style.marginLeft = "100px"
  155.  
  156. const videosSelector = isMobile ? "ytm-rich-item-renderer" : "ytd-rich-item-renderer"
  157.  
  158. let firstVideos = recommendationsContainer.querySelectorAll(videosSelector) // Because a mutation observer is being used and the script is run after the page is fully
  159. // loaded, the observer isn't triggered with the recommendations that appear first.
  160. for (let i=0; i < firstVideos.length; i++) // This does the processing manually to these first ones.
  161. processRecommendation(firstVideos[i])
  162.  
  163.  
  164. if (!isMobile) // Also, in the desktop layout, the first few recommendations require some layout tweaks to display them correctly.
  165. {
  166. const firstRows = recommendationsContainer.children
  167.  
  168. for (let i=0; i < firstRows.length; i++)
  169. {
  170. if (firstRows[i].clientHeight == 0) // Only apply the tweaks if there's any recommendation visible in the row.
  171. continue
  172.  
  173. const row = firstRows[i]
  174.  
  175. row.style.justifyContent = "left" // These two lines remove an extra space on the left side, to make them align correctly with the other ones.
  176. row.firstElementChild.style.margin = "0px"
  177.  
  178. firstVideos = row.querySelectorAll(videosSelector)
  179.  
  180. for (let j=0; j < firstVideos.length; j++)
  181. firstVideos[j].style.width = "360px" // Force the recommendations to be displayed with this size, otherwise they get "crushed".
  182. }
  183. }
  184.  
  185.  
  186. let loadedRecommendedVideosObserver
  187.  
  188. if (isMobile)
  189. {
  190. loadedRecommendedVideosObserver = new MutationObserver(function(mutations) // A mutation observer is being used so that all processings happen only
  191. { // when actually needed, which is when more recommendations are loaded.
  192. for (let i=0; i < mutations.length; i++)
  193. {
  194. for (let j=0; j < mutations[i].addedNodes.length; j++)
  195. {
  196. const node = mutations[i].addedNodes[j]
  197.  
  198. if (node.tagName == "YTM-RICH-SECTION-RENDERER") // The Covid-19 info and Breaking News section is loaded as a container of videos which need to be processed separatedly.
  199. {
  200. const sectionVideos = node.querySelectorAll(videosSelector)
  201.  
  202. for (let k=0; k < sectionVideos.length; k++)
  203. processRecommendation(sectionVideos[k])
  204. }
  205. else processRecommendation(node)
  206. }
  207. }
  208. })
  209. }
  210. else
  211. {
  212. loadedRecommendedVideosObserver = new MutationObserver(function(mutations)
  213. {
  214. for (let i=0; i < mutations.length; i++)
  215. {
  216. for (let j=0; j < mutations[i].addedNodes.length; j++)
  217. {
  218. const row = mutations[i].addedNodes[j] // Different from the mobile layout in which the recommendations are displayed as a list, in the desktop
  219. // layout the recommendations are displayed in containers that each serve as a row with 4-5 videos.
  220.  
  221. if (row.querySelector("ytd-notification-text-renderer, ytd-compact-promoted-item-renderer")) // Ignore notices and such, otherwise the layout gets messed up.
  222. continue
  223.  
  224. row.style.width = "max-content" // Make the row take only the space needed to hold the recommendations within. If the next row fits in the freed space, it will move to beside it.
  225. row.firstElementChild.style = "margin-right: 0px; margin-left: 0px;" // Remove the gap between different row containers in the same line.
  226.  
  227.  
  228. const recommendations = row.querySelectorAll("ytd-rich-item-renderer")
  229.  
  230. for (let k=0; k < recommendations.length; k++)
  231. {
  232. recommendations[k].style.width = "360px"
  233.  
  234. processRecommendation(recommendations[k])
  235. }
  236.  
  237. }
  238. }
  239. })
  240. }
  241.  
  242. loadedRecommendedVideosObserver.observe(recommendationsContainer, {childList: true})
  243.  
  244.  
  245. addRecommendationMenuItems()
  246.  
  247. }, 500)
  248. }
  249. else
  250. {
  251. const waitForRelatedVideosContainer = setInterval(function()
  252. {
  253. const videosSelector = isMobile ? "ytm-video-with-context-renderer, ytm-compact-show-renderer, ytm-radio-renderer, ytm-compact-playlist-renderer" : "ytd-compact-video-renderer, ytd-compact-radio-renderer, ytd-compact-movie-renderer, ytd-compact-playlist-renderer"
  254.  
  255. let relatedVideosContainer = document.querySelector(videosSelector)
  256.  
  257. if (!relatedVideosContainer)
  258. return
  259.  
  260. clearInterval(waitForRelatedVideosContainer)
  261.  
  262.  
  263. relatedVideosContainer = relatedVideosContainer.parentElement
  264.  
  265. const firstRelatedVideos = relatedVideosContainer.querySelectorAll(videosSelector)
  266.  
  267. for (let i=0; i < firstRelatedVideos.length; i++)
  268. processRecommendation(firstRelatedVideos[i])
  269.  
  270. const loadedRelatedVideosObserver = new MutationObserver(function(mutations)
  271. {
  272. for (let i=0; i < mutations.length; i++)
  273. {
  274. for (let j=0; j < mutations[i].addedNodes.length; j++)
  275. processRecommendation(mutations[i].addedNodes[j])
  276. }
  277. })
  278.  
  279. loadedRelatedVideosObserver.observe(relatedVideosContainer, {childList: true})
  280.  
  281.  
  282. addRecommendationMenuItems()
  283.  
  284. }, 500)
  285. }
  286.  
  287. /* Create the "Hide videos from this channel" and "Hide videos that include a text" buttons */
  288. {
  289. hideChannelButton = document.createElement(isMobile ? "button" : "div")
  290. hideChannelButton.id = "hideChannelButton"
  291. hideChannelButton.className = "menu-item-button"
  292. hideChannelButton.style = !isMobile ? "background-color: white; font-size: 14px; padding: 9px 0px 9px 56px; cursor: pointer; min-width: max-content;" : ""
  293. hideChannelButton.innerHTML = "Hide videos from this channel"
  294. hideChannelButton.onclick = function()
  295. {
  296. if (confirm("Are you sure you want to hide all videos from the channel ''" + selectedChannel + "''?"))
  297. {
  298. channelsToHide.push(selectedChannel)
  299. GM.setValue("channels", JSON.stringify(channelsToHide))
  300. }
  301.  
  302. document.body.click() // Dismiss the menu.
  303. }
  304.  
  305. hidePartialTitleButton = document.createElement(isMobile ? "button" : "div")
  306. hidePartialTitleButton.id = "hidePartialTitleButton"
  307. hidePartialTitleButton.className = hideChannelButton.className
  308. hidePartialTitleButton.style = hideChannelButton.style.cssText
  309. hidePartialTitleButton.innerHTML = "Hide videos that include a text"
  310. hidePartialTitleButton.onclick = function()
  311. {
  312. const partialText = prompt("Specify the partial title of the videos to hide. All videos that contain this text in the title will get hidden.")
  313.  
  314. if (partialText)
  315. {
  316. partialTitlesToHide.push(partialText.toLowerCase().trim())
  317. GM.setValue("partialTitles", JSON.stringify(partialTitlesToHide))
  318. }
  319.  
  320. document.body.click()
  321. }
  322. }
  323. }
  324.  
  325.  
  326. async function processRecommendation(node)
  327. {
  328. if (!node || node.className.includes("processed"))
  329. return
  330.  
  331. const videoTitleEll = node.querySelector(isMobile ? "h3, h4" : "#video-title, #movie-title")
  332.  
  333. if (!videoTitleEll)
  334. return
  335.  
  336. const videoTitleText = videoTitleEll.textContent.toLowerCase() // Convert the title's text to lowercase so that there's no distinction with uppercase letters.
  337. const videoMenuBtn = node.querySelector(isMobile ? "ytm-menu" : "ytd-menu-renderer")
  338. const timeLabelEll = node.querySelector("yt" + (isMobile ? "m" : "d") + "-thumbnail-overlay-time-status-renderer")
  339.  
  340. let videoType = ""
  341.  
  342. if (timeLabelEll)
  343. videoType = timeLabelEll.attributes[(isMobile ? "data" : "overlay") + "-style"].value // Get the type of the video, which can be a normal video, a live stream or a premiere.
  344. else if (node.querySelector(".badge-style-type-live-now")) // In the homepage of the desktop layout, the live indicator is in a different element.
  345. videoType = "LIVE"
  346.  
  347. let videoChannel, videoUrl
  348.  
  349. if (isMobile)
  350. videoChannel = videoTitleEll.nextSibling.firstChild.firstChild.textContent
  351. else
  352. videoChannel = node.querySelector(".ytd-channel-name#text").textContent
  353.  
  354. if (isMobile || isHomepage)
  355. videoUrl = cleanVideoUrl(videoTitleEll.parentElement.href) // The mix playlists and the promoted videos include ID parameters, along with the video id,
  356. else // which changes everytime it's recommended. These IDs need to be ignored to filter it correctly.
  357. videoUrl = cleanVideoUrl(node.querySelector(".details a").href)
  358.  
  359.  
  360. // Because the recommendation's side-menu is separated from the recommendations container, this listens to clicks on each three-dot
  361. // button and store in a variable in what recommendation it was clicked, to then be used by the "Hide videos from this channel" button.
  362.  
  363. if (videoMenuBtn)
  364. {
  365. videoMenuBtn.onclick = function()
  366. {
  367. selectedChannel = videoChannel
  368.  
  369. addRecommendationMenuItems()
  370. }
  371. }
  372.  
  373. if (channelsToHide.includes(videoChannel) || partialTitlesToHide.some(p => videoTitleText.includes(p)))
  374. {
  375. node.style.display = "none"
  376.  
  377. node.classList.add("processed")
  378. }
  379. else if (processedVideosList.includes("hide::"+videoUrl))
  380. {
  381. if (!isHomepage && !filterRelated)
  382. return
  383.  
  384. hideOrDimm(node, isHomepage)
  385.  
  386. node.classList.add("processed")
  387. }
  388. else
  389. {
  390. if (!node.classList.contains("offView"))
  391. {
  392. node.classList.add("offView") // Add this class to mark the recommendations waiting to be counted.
  393.  
  394. onViewObserver.observe(node) // Wait for the recommendation to appear on screen.
  395.  
  396. return // And don't do anything else until that happens.
  397. }
  398. else
  399. {
  400. node.classList.remove("offView")
  401.  
  402. onViewObserver.unobserve(node) // When the recommendation finally appears on the screen and is processed, stop observing it so it doesn't trigger the observer again.
  403. }
  404.  
  405. if (maxRepetitions == 1) // If the script is set to show only one-time recommendations, to avoid unnecessary processings,
  406. { // rightaway mark to hide, in the next time the page is loaded, every video not found in the storage.
  407. if (!isHomepage && !countRelated)
  408. return
  409.  
  410. if (videoType == "DEFAULT" || videoType == "UPCOMING" && filterPremier || videoType == "LIVE" && filterLives || !videoType)
  411. {
  412. GM.setValue("hide::"+videoUrl,"")
  413. processedVideosList.push("hide::"+videoUrl)
  414.  
  415. node.classList.add("processed")
  416. }
  417.  
  418. return
  419. }
  420. else
  421. var value = await GM.getValue(videoUrl)
  422.  
  423. if (typeof value == "undefined")
  424. {
  425. if (!isHomepage && !countRelated)
  426. return
  427.  
  428. value = 1
  429. }
  430. else
  431. {
  432. if (value >= maxRepetitions)
  433. {
  434. if (!isHomepage && !filterRelated)
  435. return
  436.  
  437. hideOrDimm(node, isHomepage)
  438.  
  439. GM.deleteValue(videoUrl)
  440. GM.setValue("hide::"+videoUrl,"")
  441. processedVideosList.push("hide::"+videoUrl)
  442.  
  443. node.classList.add("processed")
  444.  
  445. return
  446. }
  447.  
  448. if (!isHomepage && !countRelated)
  449. return
  450.  
  451. value++
  452. }
  453.  
  454. if (videoType == "DEFAULT" || videoType == "UPCOMING" && filterPremier || videoType == "LIVE" && filterLives || !videoType)
  455. GM.setValue(videoUrl, value)
  456.  
  457. node.classList.add("processed")
  458. }
  459.  
  460. }
  461.  
  462.  
  463. function addRecommendationMenuItems()
  464. {
  465. const waitForRecommendationMenu = setInterval(function()
  466. {
  467. const recommendationMenu = isMobile ? document.getElementById("menu") : document.querySelector("ytd-menu-renderer yt-icon.ytd-menu-renderer")
  468.  
  469. if (!recommendationMenu)
  470. return
  471.  
  472. clearInterval(waitForRecommendationMenu)
  473.  
  474. if (document.getElementById("hideChannelButton") || document.getElementById("hidePartialTitleButton"))
  475. return
  476.  
  477.  
  478. if (isMobile)
  479. {
  480. recommendationMenu.firstChild.appendChild(hideChannelButton)
  481. recommendationMenu.firstChild.appendChild(hidePartialTitleButton)
  482. }
  483. else
  484. {
  485. if (!document.querySelector("ytd-menu-popup-renderer"))
  486. {
  487. recommendationMenu.click()
  488. recommendationMenu.click() // The recommendation menu doesn't exist in the HTML before it's clicked for the first time. This forces it to be created and dismisses it immediately.
  489. }
  490.  
  491. const blockUserParent = document.querySelector("ytd-menu-popup-renderer")
  492. blockUserParent.style = "max-height: max-content !important; max-width: max-content !important; height: max-content !important; width: 260px !important;" // Change the max width and height so that the new item fits in the menu.
  493. blockUserParent.firstElementChild.style = "width: inherit;"
  494.  
  495. const waitForRecommendationMenuItem = setInterval(function()
  496. {
  497. const recommendationMenuItem = blockUserParent.querySelector("ytd-menu-service-item-renderer")
  498.  
  499. if (!recommendationMenuItem)
  500. return
  501.  
  502. clearInterval(waitForRecommendationMenuItem)
  503.  
  504.  
  505. recommendationMenuItem.parentElement.appendChild(hideChannelButton)
  506. recommendationMenuItem.parentElement.appendChild(hidePartialTitleButton)
  507.  
  508. }, 100)
  509. }
  510.  
  511. }, 100)
  512. }
  513.  
  514.  
  515. function cleanVideoUrl(fullUrl)
  516. {
  517. const urlSplit = fullUrl.split("?") // Separate the page path from the parameters.
  518. const paramsSplit = urlSplit[1].split("&") // Separate each parameter.
  519.  
  520. for (let i=0; i < paramsSplit.length; i++)
  521. {
  522. if (paramsSplit[i].includes("v=")) // Get the video's id.
  523. return urlSplit[0]+"?"+paramsSplit[i] // Return the cleaned video URL.
  524. }
  525. }
  526.  
  527.  
  528. function hideOrDimm(node)
  529. {
  530. if (isHomepage && dimFilteredHomepage || !isHomepage && dimFilteredRelated)
  531. node.style.opacity = 0.4
  532. else
  533. node.style.display = "none"
  534. }