您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Remove a video or channel from your homepage feed forever, even if you're not logged in to youtube.
// ==UserScript== // @name 1-click feed maintenance // @namespace https://greasyfork.runtimutd.eu.org/en/scripts/436097-1-click-feed-maintenance // @version 4.0.0 // @description Remove a video or channel from your homepage feed forever, even if you're not logged in to youtube. // @author lwkjef // @match https://www.youtube.com/ // @match https://www.youtube.com/?* // @match https://www.youtube.com/watch?* // @icon https://www.google.com/s2/favicons?domain=youtube.com // @grant GM_setValue // @grant GM_getValue // @grant GM_listValues // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js // @license MIT // ==/UserScript== /* eslint-env jquery */ // script constants const scriptName = '1-Click Feed Maintenance Userscript'; const mainPollPeriod_ms = 1000; (function(){ 'use strict' $(document).ready(async function(){ if (isWatchPage()) { if (await GM_getValue(gmTrackWatched) === 'true') { const videoId = getWatchPageVideoId(); if (!videoId) { return; } await debug(`watch page video id ${videoId}`); await rememberWatchedVideo(videoId); } } else { setInterval(mainPollCallback, mainPollPeriod_ms); } }); }()) const ytPopupContainerSelector = 'ytd-popup-container'; const ytMastheadSelector = 'div#buttons.ytd-masthead'; const ytContentsGridSelector = 'div#contents.ytd-rich-grid-renderer'; async function mainPollCallback() { await addMenuToMasthead(); await addButtonsToVideoNodes(); await autohideVideoNodes(); await automarkVideoNodes(); } // ========================= // Mutex (https://blog.jcoglan.com/2016/07/12/mutexes-and-javascript/) // ========================= let Mutex = class { constructor() { this._busy = false; this._queue = []; } }; Mutex.prototype.synchronize = function(task) { this._queue.push(task); if (!this._busy) this._dequeue(); }; Mutex.prototype._dequeue = function() { this._busy = true; var next = this._queue.shift(); if (next) { this._execute(next); } else { this._busy = false; } }; Mutex.prototype._execute = function(task) { var self = this; task().then(function() { self._dequeue(); }, function() { self._dequeue(); }); }; const actionMutex = new Mutex(); // ================================= // 1-Click Feed Maintenance Features // ================================= // internal GM keys const gmWatchedVideo = 'watched'; const gmDontLikeVideo = 'dontlike'; const gmDontRecommendChannel = 'dontrecommend'; const gmAutomark = 'automark'; const gmHidepopups = 'hidepopups'; const gmHidelists = 'hidelists'; const gmHidelivestreams = 'hidelivestreams'; const gmHidefundraisers = 'hidefundraisers'; const gmTrackWatched = 'trackwatched'; async function addMenuToMasthead() { $(ytPopupContainerSelector).each(async function() { if (!$(this).find(`#${ocMenuId}`)[0]) { this.appendChild(await createOCMenu()); } }); $(ytMastheadSelector).each(async function() { if (!$(this).find(`#${ocMenuOpenButtonId}`)[0]) { this.appendChild(await createButton(ocMenuOpenButtonId, ocMenuButtonText, ocMenuOpen)); } }); } /* * Search the children of the given node for rich items that lack 1-Click Feed Maintenance * controls, and add controls to them. */ async function addButtonsToVideoNodes() { // iterate over each rich item contained in the given node that don't already have buttons $(ytContentsGridSelector).find(videoNodeWithoutOCButtonsSelector).each(async function() { if (!isShown(this)) { await error(`selected video node that is not shown: ${getVideoId(this)}`); return; } if (!$(this).find(`#${ocAlreadyWatchedButtonId}`)[0]) { this.appendChild(await createButton(ocAlreadyWatchedButtonId, ocAlreadyWatchedButtonText, alreadyWatchedOnClick)); } if (!$(this).find(`#${ocIDontLikeTheVideoButtonId}`)[0]) { this.appendChild(await createButton(ocIDontLikeTheVideoButtonId, ocIDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick)); } if (!$(this).find(`#${ocDontRecommendId}`)[0]) { this.appendChild(await createButton(ocDontRecommendId, ocDontRecommendText, dontRecommendOnClick)); } if (await GM_getValue(gmDebug) === 'true') { if (!$(this).find(`#${ocDebugId}`)[0]) { this.appendChild(await createButton(ocDebugId, ocDebugText, debugOnClick)); } } }); } /* * Double-check whether those videos have previously been marked as watched or blocked * by this script (and Youtube is ignoring that). If so, then re-mark them and hide them. */ async function autohideVideoNodes() { $(ytContentsGridSelector).find(videoNodeShownSelector).each(async function() { if (!isShown(this)) { await error(`selected video node that is not shown: ${getVideoId(this)}`); return; } if (await GM_getValue(gmHidelists) === 'true') { const listId = getListId(this); if (listId) { await debug(`hide list mode enabled; hiding list id ${listId}`); hide(this); return; } } if (await GM_getValue(gmHidelivestreams) === 'true') { const videoId = getLivestreamId(this); if (videoId) { await debug(`hide livestream mode enabled; hiding livestream id ${videoId}`); hide(this); return; } } if (await GM_getValue(gmHidefundraisers) === 'true') { const videoId = getFundraiserId(this); if (videoId) { await debug(`hide fundraiser mode enabled; hiding fundraiser id ${videoId}`); hide(this); return; } } }); } async function automarkVideoNodes() { if (await GM_getValue(gmAutomark) === 'true') { $(ytContentsGridSelector).find(videoNodeShownSelector).each(async function() { await automarkByVideoIdState(this); await automarkByChannelIdState(this); }); } } /* * Look up whether this userscript has previously watched or blocked the given video node * (and Youtube is ignoring that). If so, then re-mark it and hide it again. */ async function automarkByVideoIdState(videoNode) { if (!isShown(videoNode)) { await error(`automarkByVideoIdState given video node that is not shown: ${videoNode}`); return; } const videoId = getVideoId(videoNode); if (!videoId) { return; } const videoIdValue = await GM_getValue(videoId); if (!videoIdValue) { return; } await debug(`automarkByVideoIdState found persisted state for video id ${videoId}: ${videoIdValue}, waiting for node to ready`); const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId); await awaitNodeAdded(videoDropdownButtonSelector); if (videoIdValue === gmWatchedVideo) { await debug(`automarkByVideoIdState marking watched video id ${videoId}`); await markAlreadyWatched(videoNode); await debug(`automarkByVideoIdState hiding watched video id ${videoId}`); hide(videoNode); } else if (videoIdValue === gmDontLikeVideo) { await debug(`automarkByVideoIdState marking blocked video id ${videoId}`); await markDontLike(videoNode); await debug(`automarkByVideoIdState hiding video for blocked video id ${videoId}`); hide(videoNode); } } /* * Look up whether this userscript has previously blocked the channel of the given video node * (and Youtube is ignoring that). If so, then re-mark it and hide it again. */ async function automarkByChannelIdState(videoNode) { if (!isShown(videoNode)) { await error(`automarkByChannelIdState given video node that is not shown: ${videoNode}`); return; } const videoId = getVideoId(videoNode); const channelId = getChannelId(videoNode); if (!videoId || !channelId) { return; } const channelIdValue = await GM_getValue(channelId); if (!channelIdValue) { return; } await debug(`automarkByChannelIdState found persisted state for channel id ${channelId} of video id ${videoId}: ${channelIdValue}, waiting for node to ready`); const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId); await awaitNodeAdded(videoDropdownButtonSelector); if (channelIdValue === gmDontRecommendChannel) { await debug(`automarkByChannelIdState marking blocked channel id ${channelId}`); await markDontRecommend(videoNode); await debug(`automarkByChannelIdState hiding video ${videoId} for blocked channel id ${channelId}`); hide(videoNode); } } // ================================= // 1-Click Feed Maintenance Controls // ================================= // oc control element ids const ocAlreadyWatchedButtonId = 'oc-already-watched'; const ocIDontLikeTheVideoButtonId = 'oc-dont-like'; const ocDontRecommendId = 'oc-dont-recommend'; const ocDebugId = 'oc-debug'; // oc control labels const ocAlreadyWatchedButtonText = 'Already Watched'; const ocIDontLikeTheVideoButtonText = 'Block Video'; const ocDontRecommendText = 'Block Channel'; const ocDebugText = 'Debug'; /* * "already watched" oc button callback */ async function alreadyWatchedOnClick(e) { e.preventDefault(); const videoNode = e.target.parentNode; const videoId = getVideoId(videoNode); await markAlreadyWatched(videoNode); await debug(`hiding watched video id ${videoId}`); hide(videoNode); await rememberWatchedVideo(videoId); } /* * "I dont like the video" oc button callback */ async function iDontLikeTheVideoOnClick(e) { e.preventDefault(); const videoNode = e.target.parentNode; const videoId = getVideoId(videoNode); await markDontLike(videoNode); await debug(`hiding blocked video id ${videoId}`); hide(videoNode); await rememberBlockedVideo(videoId); } /* * "dont recommend" oc button callback */ async function dontRecommendOnClick(e) { e.preventDefault(); const videoNode = e.target.parentNode; const videoId = getVideoId(videoNode); const channelId = getChannelId(videoNode); await markDontRecommend(videoNode); await debug(`hiding blocked channel video id ${videoId}`); hide(videoNode); await rememberBlockedChannel(channelId); } /* * "debug" oc button callback */ async function debugOnClick(e) { e.preventDefault(); const videoNode = e.target.parentNode; log(`video id: ${getVideoId(videoNode)}`); log(`list id: ${getListId(videoNode)}`); log(`livestream id: ${getLivestreamId(videoNode)}`); log(`fundraiser id: ${getFundraiserId(videoNode)}`); log(`channel id: ${getChannelId(videoNode)}`); } async function rememberWatchedVideo(videoId) { if (videoId) { await debug(`remembering watched video id ${videoId}`); await GM_setValue(videoId, gmWatchedVideo); } } async function rememberBlockedVideo(videoId) { if (videoId) { await debug(`remembering blocked video id ${videoId}`); await GM_setValue(videoId, gmDontLikeVideo); } } async function rememberBlockedChannel(channelId) { if (channelId) { await debug(`remembering blocked channel id ${channelId}`); await GM_setValue(channelId, gmDontRecommendChannel); } } // ============================= // 1-Click Feed Maintenance Menu // ============================= // oc menu element ids const ocMenuId = 'oc-menu'; const ocMenuOpenButtonId = 'oc-menu-open-button'; const ocMenuCheckboxId = 'oc-menu-checkbox'; const ocMenuLabelId = 'oc-menu-label'; const ocMenuButtonContainerId = 'oc-menu-buttons'; const ocMenuExportButtonId = 'oc-menu-export-button'; const ocMenuSaveButtonId = 'oc-menu-save-button'; const ocMenuCancelButtonId = 'oc-menu-cancel-button'; // oc menu labels const ocMenuButtonText = '1-Click Config'; const ocMenuExportButtonText = 'Export'; const ocMenuSaveButtonText = 'Save'; const ocMenuCancelButtonText = 'Cancel'; // oc menu jQuery selectors const ocMenuSelector = '#oc-menu'; /* * Create the oc menu. */ async function createOCMenu() { const menu = document.createElement(divTag); menu.id = ocMenuId; menu.class = ytPopupContainerClass; menu.style.background = 'white'; menu.style.position = 'fixed'; menu.style.width = '200px'; menu.style.height = '200px'; menu.style.zIndex = 10000; menu.style.display = 'none'; menu.appendChild(createCheckboxOCMenuItem(gmDebug)); menu.appendChild(createCheckboxOCMenuItem(gmAutomark)); menu.appendChild(createCheckboxOCMenuItem(gmHidepopups)); menu.appendChild(createCheckboxOCMenuItem(gmHidelists)); menu.appendChild(createCheckboxOCMenuItem(gmHidelivestreams)); menu.appendChild(createCheckboxOCMenuItem(gmHidefundraisers)); menu.appendChild(createCheckboxOCMenuItem(gmTrackWatched)); const menuButtonContainer = createContainer(ocMenuButtonContainerId); menuButtonContainer.appendChild(createButton(ocMenuExportButtonId, ocMenuExportButtonText, ocMenuExport)); menuButtonContainer.appendChild(createButton(ocMenuSaveButtonId, ocMenuSaveButtonText, ocMenuSave)); menuButtonContainer.appendChild(createButton(ocMenuCancelButtonId, ocMenuCancelButtonText, ocMenuCancel)); menu.appendChild(menuButtonContainer); return menu; } /* * Onclick for oc menu open button in yt masthead; open the oc menu. */ async function ocMenuOpen() { $(ocMenuSelector).each(async function() { this.style.display = ''; this.style.left = `${getClientWidth() / 2}px`; this.style.top = `${getClientHeight() / 2}px`; }); await restoreOCMenuValues(); } /* * Onclick for export button in oc menu; export all GM values, then close the oc menu. */ async function ocMenuExport() { await exportGMValues(); await ocMenuClose(); } /* * Onclick for save button in oc menu; save values currently in oc menu to GM, then close the menu. */ async function ocMenuSave() { await saveOCMenuValues(); await ocMenuClose(); } /* * Onclick for cancel button in oc menu; restore the oc menu to existing GM values, then close the oc menu. */ async function ocMenuCancel() { await restoreOCMenuValues(); await ocMenuClose(); } /* * Close the oc menu. */ async function ocMenuClose() { $(ocMenuSelector).each(async function() { this.style.display = 'none'; }); } /* * Save values currently in oc menu to GM */ async function saveOCMenuValues() { await saveOCMenuItemCheckbox(gmDebug); await saveOCMenuItemCheckbox(gmAutomark); await saveOCMenuItemCheckbox(gmHidepopups); await saveOCMenuItemCheckbox(gmHidelists); await saveOCMenuItemCheckbox(gmHidelivestreams); await saveOCMenuItemCheckbox(gmHidefundraisers); await saveOCMenuItemCheckbox(gmTrackWatched); } /* * restore the oc menu to existing GM values */ async function restoreOCMenuValues() { await restoreOCMenuItemCheckbox(gmDebug); await restoreOCMenuItemCheckbox(gmAutomark); await restoreOCMenuItemCheckbox(gmHidepopups); await restoreOCMenuItemCheckbox(gmHidelists); await restoreOCMenuItemCheckbox(gmHidelivestreams); await restoreOCMenuItemCheckbox(gmHidefundraisers); await restoreOCMenuItemCheckbox(gmTrackWatched); } /* * Create and return a new checkbox for the oc menu. */ function createCheckboxOCMenuItem(config) { const ocMenuItemContainer = createContainer(getMenuItemContainerId(config)); const checkbox = document.createElement(inputTag); checkbox.id = ocMenuCheckboxId; checkbox.type = checkboxType; ocMenuItemContainer.appendChild(checkbox); const label = document.createElement(labelTag); label.id = ocMenuLabelId; label.innerHTML = config; ocMenuItemContainer.appendChild(label); return ocMenuItemContainer; } async function restoreOCMenuItemCheckbox(gmKey) { $(getOCMenuItemCheckboxSelector(gmKey))[0].checked = await GM_getValue(gmKey) === 'true'; } async function saveOCMenuItemCheckbox(gmKey) { await GM_setValue(gmKey, $(getOCMenuItemCheckboxSelector(gmKey))[0].checked.toString()); } function getOCMenuItemCheckboxSelector(gmKey) { return `#${getMenuItemContainerId(gmKey)} #${ocMenuCheckboxId}`; } function getMenuItemContainerId(gmKey) { return `oc-menu-container-${gmKey}` } // ================= // Youtube functions // ================= // youtube constants const ytPopupContainerClass = 'ytd-popup-container'; // youtube jQuery selectors const ytAppSelector = 'ytd-app'; const videoNodeShownSelector = 'ytd-rich-item-renderer:shown'; const videoNodeWithoutOCButtonsSelector = 'ytd-rich-item-renderer:not(:has(#oc-button)):shown'; const ytPaperDialogSelector = 'ytd-popup-container tp-yt-paper-dialog'; const ytPaperDialogIDontLikeTheVideoSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I don\'t like the video")'; const ytPaperDialogIveAlreadyWatchedSelector = 'ytd-popup-container tp-yt-paper-dialog tp-yt-paper-checkbox:contains("I\'ve already watched the video")'; const ytPaperDialogSubmitSelector = 'ytd-popup-container tp-yt-paper-dialog yt-button-shape button:contains("Submit")'; const ytPaperDialogCancelSelector = 'ytd-popup-container tp-yt-paper-dialog yt-button-shape button:contains("Cancel")'; const ytIronDropdownSelector = 'ytd-popup-container tp-yt-iron-dropdown:not(:has(ytd-notification-renderer))'; const ytIronDropdownNotInterestedSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Not interested")'; const ytIronDropdownDontRecommendSelector = 'ytd-popup-container tp-yt-iron-dropdown tp-yt-paper-item:contains("Don\'t recommend channel")'; const attemptDuration = 100; const maxAttempts = 30; const resolveMutationTimeout_ms = 5000; /* * Open the iron dropdown for the given video node, then click "not interested", "tell us why", * then "already watched" */ async function markAlreadyWatched(videoNode) { actionMutex.synchronize(async function() { try { const videoId = getVideoId(videoNode); await debug(`marking watched video id ${videoId}`); await openIronDropdown(videoNode); await clickNotInterested(videoNode); await clickTellUsWhy(videoNode); await clickAlreadyWatched(videoNode); } finally { // clean up any iron dropdown or paper dialog that were mistakenly left open await closeIronDropdown(videoNode); await closePaperDialog(); } }); } /* * Open the iron dropdown for the given video node, then click "not interested", "tell us why", * then "i don't like this video" */ async function markDontLike(videoNode) { actionMutex.synchronize(async function() { try { const videoId = getVideoId(videoNode); await debug(`marking blocked video id ${videoId}`); await openIronDropdown(videoNode); await clickNotInterested(videoNode); await clickTellUsWhy(videoNode); await clickIDontLikeTheVideo(videoNode); } finally { // clean up any iron dropdown or paper dialog that were mistakenly left open await closeIronDropdown(videoNode); await closePaperDialog(); } }); } /* * Open the iron dropdown for the given video node, then click "don't recommend" */ async function markDontRecommend(videoNode) { actionMutex.synchronize(async function() { try { const videoId = getVideoId(videoNode); await debug(`marking blocked channel video id ${videoId}`); await openIronDropdown(videoNode); await clickDontRecommend(videoNode); } finally { // clean up any iron dropdown or paper dialog that were mistakenly left open await closeIronDropdown(videoNode); await closePaperDialog(); } }); } /* * click menu button on the given video node, wait for iron dropdown to be added, and return iron * dropdown node. */ async function openIronDropdown(videoNode) { const videoId = getVideoId(videoNode); const videoDropdownButtonSelector = getVideoDropdownButtonSelector(videoId); // if iron dropdown button is completely missing, then nothing to do if (!$(videoDropdownButtonSelector).length) { warn(`iron dropdown button not found for selector ${videoDropdownButtonSelector}`); return; } // hide iron dropdown, so it doesn't flicker when clicking oc buttons if (await GM_getValue(gmHidepopups) === 'true') { await hideIronDropdown(); } await doAndAwaitNodeAdded(ytIronDropdownSelector, async function() { $(videoDropdownButtonSelector).trigger('click'); }); // try to hide again, in case iron dropdown didn't exist the first time if (await GM_getValue(gmHidepopups) === 'true') { await hideIronDropdown(); } } /* * click not interested button on the given iron dropdown node, and wait for tell us why button to be added. */ async function clickNotInterested(videoNode) { const videoId = getVideoId(videoNode); const tellUsWhyButtonSelector = getTellUsWhyButtonSelector(videoId); try { if (!$(ytIronDropdownNotInterestedSelector).length) { warn(`not interested button not found for video id ${getVideoId(videoNode)}`); return; } await doAndAwaitNodeAdded(tellUsWhyButtonSelector, async function() { $(ytIronDropdownNotInterestedSelector).trigger('click'); }); } finally { await closeIronDropdown(videoNode); } } /* * click dont recommend channel button on the given iron dropdown node, and wait for iron dropdown to be * removed. */ async function clickDontRecommend(videoNode) { try { if (!$(ytIronDropdownDontRecommendSelector).length) { warn(`dont recommend button not found for video id ${getVideoId(videoNode)}`); return; } await doAndAwaitNodeRemoved(ytIronDropdownSelector, async function() { $(ytIronDropdownDontRecommendSelector).trigger('click'); }); } finally { await closeIronDropdown(videoNode); } } /* * click tell us why button on the given video node, wait for paper dialog to be added, and return paper * dialog node. */ async function clickTellUsWhy(videoNode) { const videoId = getVideoId(videoNode); const tellUsWhyButtonSelector = getTellUsWhyButtonSelector(videoId); // if paper dialog button is completely missing, then nothing to do if (!$(tellUsWhyButtonSelector).length) { warn(`tell us why button not found for video id ${getVideoId(videoNode)}`); return; } // hide paper dialog, so it doesn't flicker when clicking oc buttons if (await GM_getValue(gmHidepopups) === 'true') { await hidePaperDialog(); } await doAndAwaitNodeAdded(ytPaperDialogSelector, async function() { $(tellUsWhyButtonSelector).trigger('click'); }); // try to hide again, in case paper dialog didn't exist the first time if (await GM_getValue(gmHidepopups) === 'true') { await hidePaperDialog(); } } /* * click I dont like the video button on the given paper dialog node, click submit button on paper dialog, * and wait for paper dialog to be removed. */ async function clickIDontLikeTheVideo(videoNode) { try { if (!$(ytPaperDialogIDontLikeTheVideoSelector).length) { warn(`i dont like the video button not found for video id ${getVideoId(videoNode)}`); return; } await doAndAwaitNodeRemoved(ytPaperDialogSelector, async function() { $(ytPaperDialogIDontLikeTheVideoSelector).trigger('click'); await delay(500); $(ytPaperDialogSubmitSelector).trigger('click'); }); } finally { await closePaperDialog(); } } /* * click Ive already watched the video button on the given paper dialog node, click submit button on paper * dialog, and wait for paper dialog to be removed. */ async function clickAlreadyWatched(videoNode) { try { if (!$(ytPaperDialogIveAlreadyWatchedSelector).length) { warn(`ive already watched the video button not found for video id ${getVideoId(videoNode)}`); return; } await doAndAwaitNodeRemoved(ytPaperDialogSelector, async function() { $(ytPaperDialogIveAlreadyWatchedSelector).trigger('click'); await delay(500); $(ytPaperDialogSubmitSelector).trigger('click'); }); } finally { await closePaperDialog(); } } /* * If given video node is a list, then get the list id. Otherwise, null. */ function getListId(videoNode) { const videoTitleLink = $(videoNode).find('a#video-title-link')[0]; if (!videoTitleLink) { return null; } let match = videoTitleLink.href.match(/\/watch\?v=[^&]*&list=([^&]*)/); if (match) { return match[1]; } return null; } /* * If given video node is a livestream, then get the video id. Otherwise, null. */ function getLivestreamId(videoNode) { const liveNowBadge = $(videoNode).find('div.badge-style-type-live-now')[0]; if (!liveNowBadge) { return null; } return getVideoId(videoNode); } /* * If given video node is a fundraiser, then get the video id. Otherwise, null. */ function getFundraiserId(videoNode) { const fundraiserBadge = $(videoNode).find('div.badge-style-type-ypc')[0]; if (!fundraiserBadge) { return null; } return getVideoId(videoNode); } function getVideoSelector(videoId) { return `ytd-rich-item-renderer:has(a#video-title-link[href*="${videoId}"])`; } function getVideoDropdownButtonSelector(videoId) { return `${getVideoSelector(videoId)} div#menu button` } function getTellUsWhyButtonSelector(videoId) { return `${getVideoSelector(videoId)} button:contains("Tell us why")` } function getVideoId(videoNode) { const videoTitleLink = $(videoNode).find('a#video-title-link')[0]; if (!videoTitleLink) { return null; } let match = videoTitleLink.href.match(/\/watch\?v=([^&]*)/); if (match) { return match[1]; } return null; } function getChannelId(videoNode) { const channelName = $(videoNode).find('ytd-channel-name a.yt-simple-endpoint')[0]; if (!channelName) { return null; } let match = channelName.href.match(/\/c\/([^&]*)/); if (match) { return match[1]; } match = channelName.href.match(/\/channel\/([^&]*)/); if (match) { return match[1]; } match = channelName.href.match(/\/user\/([^&]*)/); if (match) { return match[1]; } return null; } function isWatchPage() { const match = window.location.href.match(/\/watch/); if (!match) { return false; } return true; } /* * If this script is running on a video watch page, then get the video id from the watch url. */ function getWatchPageVideoId() { return window.location.href.match(/\/watch\?v=([^&]*)/)[1]; } async function hideIronDropdown() { $(ytIronDropdownSelector).each(async function() { hide(this); }); } /* * click iron dropdown button on the given video node, and wait for iron dropdown to be removed */ async function closeIronDropdown(videoNode) { await $(ytIronDropdownSelector).each(async function() { // reset visibility in case hidepopups is enabled unhide(this); // try to close it by clicking let attempts = 0; while (isShown(this)) { clickEmptySpace(); await delay(attemptDuration); attempts = attempts + 1; if (attempts > maxAttempts) { // if it's still open, then just hide it so we don't block forever on this await hideIronDropdown(); break; } } // even if it's closed, click empty space on the page to try to avoid the scroll-breaking bug clickEmptySpace(); }); } async function hidePaperDialog() { $(ytPaperDialogSelector).each(async function() { hide(this); }); } /* * click cancel button on the paper dialog, and wait for paper dialog to be removed */ async function closePaperDialog() { await $(ytPaperDialogSelector).each(async function() { // reset visibility in case hidepopups is enabled unhide(this); // try to close it by clicking empty space on the page let attempts = 0; while (isShown(this)) { clickEmptySpace(); await delay(attemptDuration); attempts = attempts + 1; if (attempts > maxAttempts) { // if it's still open, then just hide it so we don't block forever on this await hidePaperDialog(); break; } } // even if it's closed, click empty space on the page to try to avoid the scroll-breaking bug clickEmptySpace(); }); } /* * click empty space on the page to trigger youtube events that close dialogs and unblock scrolling */ function clickEmptySpace() { $(ytAppSelector).trigger('click'); } // ========================= // lwkjef's standard library // ========================= // library constants const divTag = 'div'; const buttonTag = 'button'; const buttonType = 'button'; const inputTag = 'input'; const labelTag = 'label'; const checkboxType = 'checkbox'; // library GM keys const gmDebug = 'debug'; // library selectors const rootSelector = ':root'; /* * Log the given text if debug mode is enabled. */ async function debug(text) { if (await GM_getValue(gmDebug) === 'true') { log(text); } } /* * Export all GM values as a CSV string, and open browser save file dialog to download it as a file. */ async function exportGMValues() { const csv = await getGMValuesCSV(); exportTextFile(csv); } /* * Export all GM values as a CSV string. * * Example: 'key,value\nval1,val2' */ async function getGMValuesCSV() { let csv = 'key,value\and'; for (const gmKey of await GM_listValues()) { csv += `${gmKey},${await GM_getValue(gmKey)}\and`; } return csv; } /* * Wait until at least one node matching nodeSelector is added to ancestorNodeSelector. */ async function awaitNodeAdded(expectedSelector) { await debug(`awaitNodeAdded awaiting ${expectedSelector} added...`); const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) { const expectedNode = $(expectedSelector)[0]; if (expectedNode) { resolve(expectedNode); } }, _ => {}); if (!resultNode) { await error(`awaitNodeAdded couldn't resolve addition of ${expectedSelector}`); } return resultNode; } /* * Execute the given function, then wait until node is added. ancestorSelector MUST exist and should be as * close as possible to target node for efficiency. */ async function doAndAwaitNodeAdded(expectedSelector, triggerMutation) { const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) { const expectedNode = $(expectedSelector)[0]; if (expectedNode && isShown(expectedNode)) { resolve(expectedNode); } }, triggerMutation); if (!resultNode) { await error(`doAndAwaitNodeAdded couldn't resolve addition of ${expectedSelector}`); } return resultNode; } /* * Execute the given function, then wait until given node is removed. ancestorSelector MUST exist and * should be as close as possible to target node for efficiency. */ async function doAndAwaitNodeRemoved(expectedSelector, triggerMutation) { const resultNode = await resolveMutation(async function(mutationsList, observer, resolve) { const expectedNode = $(expectedSelector)[0]; if (expectedNode && !isShown(expectedNode)) { resolve(expectedNode); } }, triggerMutation); if (!resultNode) { await error(`doAndAwaitNodeRemoved couldn't resolve removal of ${expectedSelector}`); } return resultNode; } async function resolveMutation(onMutation, triggerMutation) { await debug(`entering resolveMutation`); let observer; try { const resultNode = await new Promise(resolve => { observer = new MutationObserver(async function(mutationsList, observer) { onMutation(mutationsList, observer, resolve); }); $(rootSelector).each(async function() { observer.observe(this, {attributes: true, childList: true, subtree: true}); }); setTimeout(async function() { observer.disconnect(); resolve(null); }, resolveMutationTimeout_ms); triggerMutation(); }); if (!resultNode) { await error(`resolveMutation couldn't resolve mutation`); } return resultNode; } finally { observer.disconnect(); } } /* * Get the current width of the browser, in pixels. */ function getClientWidth() { return (window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth); } /* * Get the current height of the browser, in pixels. */ function getClientHeight() { return (window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight); } /* * Create and return a new div container with the given id. */ function createContainer(id) { const container = document.createElement(divTag); container.id = id; return container; } /* * Create and return a new button with the given id, text, onclick callback, and css style. */ function createButton(id, text, onclick, cssObj = {'background': 'grey', 'margin-left': '2px', 'margin-right': '2px', 'padding': '2px', 'font-size': '14px'}) { const button = document.createElement(buttonTag); button.id = id; button.type = buttonType; button.innerHTML = text; button.onclick = onclick; Object.keys(cssObj).forEach(key => {button.style[key] = cssObj[key]}); return button; } /* * Return true if node is displayed and visible, false otherwise. */ function isShown(node) { return node.style.display !== 'none' && node.style.visibility != 'hidden'; } /* * Hide the given node by setting the visibility style to hidden */ function hide(node) { node.style.visibility = 'hidden'; } /* * Unhide the given node by unsetting the visibility style (assuming it was set to hidden) */ function unhide(node) { node.style.visibility = ''; } /* * Prepend the script name to the given text */ function prependScriptName(text) { return `[${scriptName}] ${text}`; } /* * Log the given text, prepended by the userscript name, to the browser console at standard log level */ function log(text) { console.log(prependScriptName(text)); } /* * Log the given text, prepended by the userscript name, to the browser console at warn log level */ function warn(text) { console.warn(prependScriptName(text)); } /* * Log the given text, prepended by the userscript name, to the browser console at error log level */ function error(text) { console.error(prependScriptName(text)); } /* * Return a Promise which waits for the given millisecond duration. */ async function delay(duration_ms) { await new Promise((resolve, reject) => { setTimeout(_ => resolve(), duration_ms) }); } /* * Return true if strings lowercased are equivalent, false otherwise. */ function equalsIgnoreCase(one, two) { return one.toLowerCase() === two.toLowerCase(); } /* * Export the given data as a text file and trigger browser to download it. * * type may be 'text/csv', 'text/html', 'text/vcard', 'text/txt', 'application/csv', etc. * * Example usage: exportFile('col1,col2\nval1,val2', 'text/csv'); */ function exportTextFile(data, type='text/csv', charset='utf-8', filename='data.csv') { // IMPORTANT: we must manually revoke this object URL to avoid memory leaks! const objectURL = window.URL.createObjectURL(new Blob([data], {type: type})); // alternatively, we may create object URL manually //const objectURL = `data:${type};charset=${charset},${encodeURIComponent(data)}` try { triggerObjectURL(objectURL, filename); } finally { if (objectURL !== null) { window.URL.revokeObjectURL(objectURL); } } } /* * Trigger the browser to download the given objectURL as a file. */ function triggerObjectURL(objectURL, filename='data.csv') { const a = document.createElement('a'); a.href = objectURL; //supported by chrome 14+ and firefox 20+ a.download = filename; const body = document.getElementsByTagName('body')[0]; //needed for firefox body.appendChild(a); //supported by chrome 20+ and firefox 5+ a.click(); // clean up body.removeChild(a); } /* * jQuery extension to match elements that don't have display none nor css visibility hidden. * The built-in :visible selector stil matches elements with css visibility hidden because * they reserve space in the layout, but sometimes we want to actually match based on visibility * of the content rather than the layout space. * * https://stackoverflow.com/a/33689304 */ jQuery.extend(jQuery.expr[':'], { shown: function (el, index, selector) { return $(el).css('visibility') != 'hidden' && $(el).css('display') != 'none' && !$(el).is(':hidden') } });