Exports videos from your YouTube Watch Later page to a JSON file
// ==UserScript== // @name Watch Later Extractor // @namespace rbits.watch-later-extractor // @version 0.0.4 // @description Exports videos from your YouTube Watch Later page to a JSON file // @author rbits // @match https://www.youtube.com/playlist* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant GM_registerMenuCommand // @license GPL3 // ==/UserScript== function runScript() { console.log("Watch Later Extractor script running"); let box = document.createElement("div"); box.style = ` color: white; background-color: #555555; border-radius: 2rem; width: 50rem; height: 20rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2rem; padding: 2rem; `; let textElement = document.createElement("p"); textElement.innerHTML = "Enter id/url of video to stop at<br>(leave blank to process all videos)" textElement.style = ` font-size: 2rem; text-align: center; ` box.appendChild(textElement); let videoIdInput = document.createElement("input"); videoIdInput.style = ` font-size: 2rem; width: 80%; ` box.appendChild(videoIdInput); let fileType = document.createElement("select"); fileType.innerHTML = ` <option value="json">JSON</option> <option value="csv">CSV</option> `; fileType.style = ` font-size: 2rem; ` box.appendChild(fileType); let button = document.createElement("button"); button.textContent = "Start"; button.style = ` font-size: 2rem; padding: 0.5rem; ` box.appendChild(button); let flex = document.createElement("div"); flex.style = ` width: 100vw; height: 100vh; position: fixed; display: flex; align-items: center; justify-content: center; z-index: 9999; ` flex.appendChild(box); document.body.appendChild(flex); button.onclick = () => { startProcessing(videoIdInput.value, fileType.value); document.body.removeChild(flex); }; } function startProcessing(stopVideoId, fileType) { // Convert url to id const videoIdMatch = stopVideoId.match(/\/watch\?v=([^&]*)/); if (videoIdMatch) { stopVideoId = videoIdMatch[1]; } let videosElement = document.querySelector("ytd-playlist-video-renderer").parentElement; let signals = { allLoaded: false, } parseVideos(videosElement, stopVideoId, signals) .then((parsedVideos) => handleParsedVideos(parsedVideos, fileType)); repeatScroll(videosElement, signals); } // Parses all videos as they appear in videos // Once signals.allLoaded is set, it finishes parsing all remaining videos then // returns list of parsed videos async function parseVideos(videosElement, stopVideoId, signals) { console.log("Starting video parsing"); if (stopVideoId !== "") { console.log("Stopping at %s", stopVideoId); } let videos = videosElement.children; let parsedVideos = []; let i = 0; let didFinishEarly = false; // videos can grow at any time while (true) { while (i < videos.length - 1) { const parsedVideo = parseVideo(videos.item(i)); parsedVideos.push(parsedVideo); i++; if (parsedVideo.videoId === stopVideoId) { didFinishEarly = true; signals.allLoaded = true; break; } } if (signals.allLoaded) { break; } console.log("Parsed %d videos, waiting for more videos", i); while (i >= videos.length - 1 && !signals.allLoaded) { // Wait 0.1s between checks await new Promise(executor => setTimeout(executor, 100)) } } // Usually last item is ytd-continuation-item-renderer so isn't parsed // It's probably a video now, so it should be parsed now if (isEnd(videos) && !didFinishEarly) { const lastItem = videos.item(videos.length - 1); parsedVideos.push(parseVideo(lastItem)); } else { console.log("Exited early: parsing finished but continuation item still exists"); } return parsedVideos; } function parseVideo(videoElement) { // const thumbnail = videoElement.getElementsByTagName("img")[0].src; const titleElement = videoElement.querySelector("#video-title"); const videoUrl = titleElement.href; const videoId = videoUrl.match(/\/watch\?v=([^&]*)/)[1]; const title = titleElement.title; const channelElement = videoElement.querySelector("#channel-name") .getElementsByTagName("a")[0]; const channelUrl = channelElement.href; const channelName = channelElement.textContent; return { title, channelName, videoUrl, videoId, channelUrl, // thumbnail, }; } async function repeatScroll(videosElement, signals) { let videos = videosElement.children; // No need to scroll, already loaded if (isEnd(videos)) { signals.allLoaded = true; return; } const mutationCallback = (_mutationList, observer) => { if (isEnd(videos) || signals.allLoaded) { signals.allLoaded = true; observer.disconnect(); } else { scrollToBottom() } } const observer = new MutationObserver(mutationCallback); observer.observe(videosElement, { childList: true }); scrollToBottom(); } function scrollToBottom() { window.scroll(0, document.documentElement.scrollHeight); console.log("Scrolled to " + document.documentElement.scrollHeight); } function isEnd(videos) { const lastItem = videos.item(videos.length - 1); if (lastItem.tagName === "YTD-PLAYLIST-VIDEO-RENDERER") { return true; } else if (lastItem.tagName === "YTD-CONTINUATION-ITEM-RENDERER") { return false; } else { console.error(lastItem.tagName); throw new Error("Unknown item in video list"); } } function handleParsedVideos(parsedVideos, fileType) { console.log("All videos parsed, creating file"); let fileString = ""; if (fileType === "json") { fileString = JSON.stringify(parsedVideos); } else if (fileType === "csv") { fileString = objListToCsv(parsedVideos); } const base64String = stringToBase64(fileString); var downloadLink = document.createElement("a"); downloadLink.href = "data:text/plain;base64," + base64String; downloadLink.download = "playlist." + fileType; downloadLink.click(); // console.dir(parsedVideos); } // From https://developer.mozilla.org/en-US/docs/Glossary/Base64 function stringToBase64(string) { const bytes = new TextEncoder().encode(string); const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte), ).join(""); return btoa(binString); } function objListToCsv(objList) { const columns = Object.keys(objList[0]); let rows = []; for (const obj of objList) { let row = ""; let first = true; for (const column of columns) { if (first) { first = false; } else { row += ","; } // Surround in quotes and escape quotes row += "\"" + obj[column].replaceAll("\"", "\"\"") + "\""; } rows.push(row); } let csv = columns.join(",") + "\n"; csv += rows.join("\n"); return csv; } (function() { 'use strict'; GM_registerMenuCommand( "Run script", runScript, ); })();