您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a button to remove videos watched with more than X percent from watch later playlist.
当前为
// ==UserScript== // @name YT Playlists Delete Enhancer // @version 1.4.1 // @description Add a button to remove videos watched with more than X percent from watch later playlist. // @author avallete // @homepage https://github.com/avallete/yt-playlists-delete-enhancer // @support https://github.com/avallete/yt-playlists-delete-enhancer/issues // @require https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.8.7/polyfill.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/src/js.cookie.min.js // @grant none // @include *//www.youtube.com/* // @namespace https://greasyfork.org/fr/users/70224-avallete // @noframes false // @run-at document-idle // @licence MIT // ==/UserScript== class GMScript { constructor(ytcfgdata, playlistVideos, playlistName) { this.ytcfgdata = ytcfgdata; this.playlistVideos = playlistVideos; this.playlistName = playlistName; this.baseRequestHeaders = { "Content-Type": "application/json", "X-Goog-Visitor-Id": this.ytcfgdata["VISITOR_DATA"], "X-Youtube-Client-Name": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"], "X-Youtube-Client-Version": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"], // Those two are mandatory together to successfully perform request "X-Goog-AuthUser": "0", "X-Goog-PageId": this.ytcfgdata["DELEGATED_SESSION_ID"], }; } // Get the array of "playlistVideoRenderer" either from continuationsItems or playlistVideoListRenderer // The last one should contain the continuation token if there is any. // Return null otherwise getPlaylistContinuationToken(playlistVideoListRendererContents) { const lastItem = playlistVideoListRendererContents[playlistVideoListRendererContents.length - 1]; if (lastItem && lastItem["continuationItemRenderer"]) { return _.get(lastItem, "continuationItemRenderer.continuationEndpoint.continuationCommand.token"); } return null; } enableRemoveButton() { const button = document.getElementById("removeVideosEnhancerButton"); if (button) { button.disabled = false; } } disableRemoveButton() { const button = document.getElementById("removeVideosEnhancerButton"); if (button) { button.disabled = true; } } // Generate SAPISIDHASH header getAuthorizationHeader() { const time = Math.floor(Date.now() / 1000); const origin = new URL(document.URL).origin; const apisid = window.Cookies.get("SAPISID"); const shash = window.sha1(`${time} ${apisid} ${origin}`); return `SAPISIDHASH ${time}_${shash}`; } getRequestHeaders() { return { ...this.baseRequestHeaders, "Authorization": this.getAuthorizationHeader(), } } async getAllPlaylistVideos() { let playlistItems = this.playlistVideos; let continuationToken = this.getPlaylistContinuationToken(playlistItems); // If there is continuations, it mean that the playlist is not fully loaded, // Request additional data until not futher videos to fetch while (continuationToken) { // Remove the last item from the playlist content wich is not a video but the object with continuation data playlistItems.pop() const body = { "context": { // The only mandatory context are those two client infos "client": { "clientName": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"], "clientVersion": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"], } }, "continuation": continuationToken, } let resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${this.ytcfgdata["INNERTUBE_API_KEY"]}`, { "credentials": "include", "headers": this.getRequestHeaders(), "body": JSON.stringify(body), "referrer": `https://www.youtube.com/playlist?list=${this.playlistName}`, "method": "POST", "mode": "cors" }); if (resp.status === 200) { const respjson = await resp.json(); const data = _.get(respjson, "onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems") playlistItems = playlistItems.concat(data); continuationToken = this.getPlaylistContinuationToken(data); } } return playlistItems; } async removeVideosFromPlaylist(playlistId, videoIds) { const body = { actions: videoIds.map((vid) => ({"setVideoId": vid, "action": "ACTION_REMOVE_VIDEO"})), "context": { // The only mandatory context are those two client infos "client": { "clientName": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"], "clientVersion": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"], } }, params: "CAFAAQ%3D%3D", playlistId: playlistId, } const params = { "credentials": "include", "headers": this.getRequestHeaders(), "referrer": `https://www.youtube.com/playlist?list=${this.playlistName}`, "body": JSON.stringify(body), "method": "POST", "mode": "cors" }; const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse/edit_playlist?key=${this.ytcfgdata["INNERTUBE_API_KEY"]}`, params); if (resp.status === 200) { return await resp.json(); } return false; } getVideosIdsToDelete(watchTimeValue, playlistVideos) { return playlistVideos .filter((itm) => !!_.get({itm}, 'itm.playlistVideoRenderer.thumbnailOverlays')) .filter( ({playlistVideoRenderer: {thumbnailOverlays: [, overlay]}}) => ( // If it's not the second element in array, the videos haven't been played yet overlay.thumbnailOverlayResumePlaybackRenderer && overlay.thumbnailOverlayResumePlaybackRenderer.percentDurationWatched >= watchTimeValue ) ) // There was a reason for this "setVideoId", it's because they are not the same with videoId // And we DO NEED the "sedVideoId" value to perform remove requests. .map(({playlistVideoRenderer: vid}) => (vid.setVideoId || vid.videoId)); } async handleRemoveVideosClickedEvent(watchTimeValue) { this.disableRemoveButton(); let idsToDelete = this.getVideosIdsToDelete(watchTimeValue, this.playlistVideos); if (idsToDelete.length) { const respjson = await this.removeVideosFromPlaylist(this.playlistName, idsToDelete); if (respjson.status === "STATUS_SUCCEEDED") { idsToDelete.forEach(id => { const videoId = this.playlistVideos.filter(v => v.playlistVideoRenderer.setVideoId == id)[0].playlistVideoRenderer.videoId; const videoRenderer = document.querySelector(`ytd-playlist-video-renderer a[href^="/watch?v=${videoId}"]#thumbnail`); if (videoRenderer) { videoRenderer.parentElement.parentElement.parentElement.parentElement.style.display = 'none'; } else { window.location.reload(); } }, this); } } this.enableRemoveButton(); } constructDOM() { return document.createRange().createContextualFragment(` <div id="yt-remove-video-enhancer-container" class="style-scope ytd-playlist-sidebar-renderer"> <div class="style-scope ytd-menu-service-item-renderer" role="option" tabindex="0" aria-disabled="false"> <p>Remove all videos who has been watched at more or equal X percent</p> <input id="removeVideosEnhancerValue" type="number" min="0" max="100" value="99"> <button id="removeVideosEnhancerButton">Remove !</button> </div> </div>` ); } createEventsListeners(DOMFragment) { const input = DOMFragment.getElementById("removeVideosEnhancerValue"); const button = DOMFragment.getElementById("removeVideosEnhancerButton"); button.addEventListener('click', () => this.handleRemoveVideosClickedEvent(input.value)); } appendDOM(DOMFragment) { const container = document.evaluate('//ytd-playlist-sidebar-renderer/div[@id="items"]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; container.appendChild(DOMFragment); } run() { const domFragment = this.constructDOM(); this.createEventsListeners(domFragment); this.appendDOM(domFragment); this.disableRemoveButton(); this.getAllPlaylistVideos() .then((playlistContent) => { this.playlistVideos = playlistContent; this.enableRemoveButton(); }) .catch((err) => { console.error(err); console.error(this); }); } } function cleanupDOM() { // Destroy every DOM elements created by the script const extendedDOM = document.getElementById("yt-remove-video-enhancer-container"); if (extendedDOM) { extendedDOM.parentNode.removeChild(extendedDOM); } } async function getFirstPlaylistData(ytcfgdata, playlistName) { const url = `https://www.youtube.com/playlist?list=${playlistName}&pbj=1`; let resp = await fetch(url, { "credentials": "include", "headers": { "X-YouTube-Client-Name": ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"], "X-YouTube-Client-Version": ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"], "X-YouTube-Device": ytcfgdata["DEVICE"], "X-Youtube-Identity-Token": ytcfgdata["ID_TOKEN"], "X-YouTube-Page-CL": ytcfgdata["PAGE_CL"], "X-YouTube-Page-Label": ytcfgdata["PAGE_BUILD_LABEL"], }, "referrer": `https://www.youtube.com/playlist?list=${playlistName}`, "method": "GET", "mode": "cors" }); const jsondata = await resp.json(); return _.get({jsondata}, 'jsondata[1].response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].playlistVideoListRenderer'); } async function getInitiaPlaylistVideoListRenderer(ytcfgdata, playlistName) { return await getFirstPlaylistData(ytcfgdata, playlistName); } async function main(playlistName) { try { // Prefetched initial datas present in the page const ytcfgdata = window.ytcfg.data_; // configuration of youtube app containing auth tokens const playlistVideoRenderer = await getInitiaPlaylistVideoListRenderer(ytcfgdata, playlistName); if (ytcfgdata && playlistVideoRenderer && playlistVideoRenderer.isEditable) { const script = new GMScript(ytcfgdata, playlistVideoRenderer.contents || [], playlistName); script.run(); } else { console.error('Missing ytconfig or playlist data or playlist is not editable: ', ytcfgdata, playlistVideoRenderer); } } catch (err) { console.error(err); } } // The following conditions and check are here to mitigate the "virtual" navigation of youtube // Without this fix, Tampermonkey fail to load our script on youtube without a full page reload. let url = new URL(window.location.href); if (url.pathname === '/playlist' && url.searchParams.get("list") !== null) { const playlistName = url.searchParams.get("list"); main(playlistName).catch(console.error); } history.pushState = (f => function pushState() { let ret = f.apply(this, arguments); window.dispatchEvent(new Event('pushstate')); window.dispatchEvent(new Event('locationchange')); return ret; })(history.pushState); history.replaceState = (f => function replaceState() { let ret = f.apply(this, arguments); window.dispatchEvent(new Event('replacestate')); window.dispatchEvent(new Event('locationchange')); return ret; })(history.replaceState); window.addEventListener('popstate', () => { window.dispatchEvent(new Event('locationchange')) }); window.addEventListener('yt-navigate-finish', () => { window.dispatchEvent(new Event('locationchange')) }); window.addEventListener('locationchange', function () { url = new URL(window.location.href); if (url.pathname === '/playlist' && url.searchParams.get("list") !== null) { const playlistName = url.searchParams.get("list"); main(playlistName).catch(console.error); } else { cleanupDOM(); } });