Script to clean up YouTube history and bulk-delete videos that mess up the recommendation algorithm
| // @ts-check | |
| /** | |
| * @author Joshua Tzucker (joshuatz.com) | |
| * Script to clean up YouTube history and bulk-delete videos that mess up the recommendation algorithm | |
| * (e.g., low-quality YouTube Shorts entries) | |
| * | |
| * To use, first navigate to your Google Account -> My Activity -> YouTube | |
| * or just go here: https://myactivity.google.com/product/youtube | |
| * Then, copy and paste the below code. See `run_examples.js` for examples of how to initialize and run. | |
| * | |
| * Inspired by: https://gist.github.com/miketromba/334282421c4784d7d9a191ca25095c09 | |
| */ | |
| const injectorStyles = ` | |
| .ythc-overlay { | |
| z-index: 9999; | |
| position: fixed; | |
| bottom: 10px; | |
| right: 10px; | |
| background-color: white; | |
| width: 600px; | |
| height: 400px; | |
| text-align: center; | |
| overflow: hidden; | |
| } | |
| .ythc-overlay .top-bar { | |
| display: flex; | |
| flex-direction: row; | |
| align-items: center; | |
| justify-content: space-evenly; | |
| } | |
| .ythc-overlay .container { | |
| height: calc(100% - 39px); | |
| overflow: scroll; | |
| } | |
| .ythc-overlay table { | |
| overflow-x: auto; | |
| width: 100% !important; | |
| max-width: -moz-fit-content; | |
| max-width: fit-content; | |
| display: block; | |
| margin: 0px auto 8px auto; | |
| } | |
| .ythc-overlay tbody tr td { | |
| border-bottom: 1px dashed black; | |
| } | |
| // Not for overlay, but actual YouTube UI | |
| // Make date dividers easier to notice | |
| div[data-date][jscontroller] { | |
| width: 120%; | |
| margin-left: -10%; | |
| } | |
| div[data-date][jscontroller] > div > div { | |
| background-color: red; | |
| font-size: 2rem; | |
| } | |
| div[data-date][jscontroller] > div > div > * { | |
| color: white; | |
| font-size: 3rem; | |
| } | |
| `; | |
| const _window = /** @type {typeof window & {ytHistoryCleaner: YouTubeHistoryCleaner}} */ (window); | |
| /** | |
| * @typedef {object} VideoWithDetails | |
| * @property {string} title | |
| * @property {string} author | |
| * @property {string} durationString | |
| * @property {number} durationInSeconds | |
| * @property {string} thumbnailHref | |
| * @property {HTMLImageElement} thumbnailImgTag | |
| * @property {string} videoLink | |
| * @property {string} videoId | |
| * @property {HTMLButtonElement} deleteButton | |
| */ | |
| /** | |
| * @typedef {Partial<VideoWithDetails> & Pick<Required<VideoWithDetails>, 'videoLink' | 'videoId' | 'deleteButton'>} DeletedVideo | |
| */ | |
| /** @typedef {VideoWithDetails | DeletedVideo} Video */ | |
| /** @typedef {(video: Video) => boolean} Filter */ | |
| class YouTubeHistoryCleaner { | |
| injectedIntoDOM = false; | |
| delayMs = 1200; | |
| dryRunMode = true; | |
| /** @type {Video[]} */ | |
| videoList = []; | |
| /** @type {Filter[]} */ | |
| filters = []; | |
| /** @type {string[]} */ | |
| dontDeleteIds = []; | |
| // UI | |
| /** @type {HTMLDivElement} */ | |
| container; | |
| passthroughPolicy = { | |
| createHTML: (input = '') => input, | |
| }; | |
| /** | |
| * @param {Filter[] | undefined} filters | |
| */ | |
| constructor(filters = undefined, dryRunMode = true) { | |
| this.dryRunMode = dryRunMode; | |
| _window.ytHistoryCleaner = this; | |
| if ('trustedTypes' in window) { | |
| // @ts-ignore | |
| this.passthroughPolicy = window.trustedTypes.createPolicy('passThrough', { | |
| createHTML: (input = '') => input, | |
| }); | |
| } | |
| if (filters) { | |
| this.filters = filters; | |
| } else { | |
| // Default filter, video must be < 1:20 to ask for deletion | |
| this.filters = [YouTubeHistoryCleaner.ShortsFilter]; | |
| } | |
| this.populate(); | |
| } | |
| getTrustedHtml(inputHtml = '') { | |
| return this.passthroughPolicy.createHTML(inputHtml); | |
| } | |
| populate() { | |
| this.videoList = []; | |
| /** @type {NodeListOf<HTMLDivElement>} */ | |
| const videoEntries = document.querySelectorAll('div[aria-label^="Card showing an activity from YouTube"]'); | |
| for (const videoEntry of videoEntries) { | |
| /** @type {Video} */ | |
| let videoDetails; | |
| try { | |
| videoDetails = YouTubeHistoryCleaner.parseVideoEntry(videoEntry); | |
| } catch (err) { | |
| console.warn(`Error extracting details for video`, err, videoEntry); | |
| continue; | |
| } | |
| let filteredOut = false; | |
| for (const filter of this.filters) { | |
| if (filter(videoDetails) == false) { | |
| filteredOut = true; | |
| break; | |
| } | |
| } | |
| filteredOut = filteredOut || this.dontDeleteIds.includes(videoDetails.videoId); | |
| if (filteredOut) { | |
| continue; | |
| } | |
| this.videoList.push(videoDetails); | |
| } | |
| this.render(); | |
| } | |
| async deleteBulk(dryRunMode = this.dryRunMode) { | |
| for (const video of this.videoList) { | |
| if (this.dontDeleteIds.includes(video.videoId)) { | |
| continue; | |
| } | |
| const deleteText = `${dryRunMode ? 'Pretending to delete' : 'Deleting'} ${video.title}, by ${video.author || 'Unknown (deleted?)'}`; | |
| const consoleTarget = dryRunMode ? console.log : console.warn; | |
| consoleTarget(deleteText); | |
| if (dryRunMode) { | |
| continue; | |
| } | |
| // DANGER | |
| video.deleteButton.click(); | |
| this.dontDeleteIds.push(video.videoId); | |
| console.log(`Waiting for ${this.delayMs}`); | |
| await new Promise((res) => setTimeout(res, this.delayMs)); | |
| } | |
| // Update | |
| console.log( | |
| `=== Bulk delete done! ===\n\nPlease wait for all delete operations to finish (watch network tab). Then click "populate" if you want to run another batch.` | |
| ); | |
| this.populate(); | |
| } | |
| ensureInjection() { | |
| if (!this.injectedIntoDOM) { | |
| const overlay = document.createElement('div'); | |
| document.body.appendChild(overlay); | |
| overlay.classList.add('ythc-overlay'); | |
| // Title / top bar | |
| overlay.insertAdjacentHTML( | |
| 'afterbegin', | |
| this.getTrustedHtml( | |
| '<div class="top-bar"><h2>Video History Delete:</h2><button class="populate">Populate</button><button class="delete">Delete!</button></div>' | |
| ) | |
| ); | |
| const container = document.createElement('div'); | |
| container.classList.add('container'); | |
| this.container = container; | |
| overlay.appendChild(container); | |
| const styleElement = document.createElement('style'); | |
| styleElement.textContent = injectorStyles; | |
| document.body.appendChild(styleElement); | |
| // Attach event listeners | |
| overlay.querySelector('button.populate').addEventListener('click', () => { | |
| this.populate(); | |
| }); | |
| overlay.querySelector('button.delete').addEventListener('click', () => { | |
| this.deleteBulk(); | |
| }); | |
| } | |
| this.injectedIntoDOM = true; | |
| } | |
| render() { | |
| const videoList = this.videoList; | |
| this.ensureInjection(); | |
| this.container.innerHTML = this.getTrustedHtml(''); | |
| if (!videoList || !videoList.length) { | |
| return; | |
| } | |
| const table = document.createElement('table'); | |
| /** @type {Array<[string, (video: Video) => string | HTMLElement]>} */ | |
| const mappings = [ | |
| ['Video Title', (video) => video.title || 'Unknown (deleted?)'], | |
| ['Author', (video) => 'author' in video ? video.author : 'Unknown (deleted?)'], | |
| ['Duration', (video) => video.durationString], | |
| ['Exclude', (video) => `<button class="exclude">Exclude</button>`], | |
| ]; | |
| // Create header row | |
| table.insertAdjacentHTML( | |
| 'afterbegin', | |
| this.getTrustedHtml(`<thead><tr>${mappings.map((m) => `<td>${m[0]}</td>`).join('')}</tr></thead>`) | |
| ); | |
| // Create body | |
| const tableBody = document.createElement('tbody'); | |
| table.appendChild(tableBody); | |
| for (const video of videoList) { | |
| if (this.dontDeleteIds.includes(video.videoId)) { | |
| continue; | |
| } | |
| const tableRow = document.createElement('tr'); | |
| tableRow.setAttribute('data-video-id', video.videoId); | |
| mappings.forEach((m) => { | |
| const tableCell = document.createElement('td'); | |
| const contents = m[1](video); | |
| if (typeof contents === 'string') { | |
| tableCell.innerHTML = this.getTrustedHtml(contents); | |
| } else { | |
| tableCell.appendChild(contents); | |
| } | |
| tableRow.appendChild(tableCell); | |
| }); | |
| tableBody.appendChild(tableRow); | |
| } | |
| this.container.appendChild(table); | |
| // Register button listeners | |
| table.querySelectorAll('button.exclude').forEach( | |
| /** @type {HTMLButtonElement} */ (_button) => { | |
| _button.addEventListener('click', (evt) => { | |
| const button = /** @type {HTMLButtonElement} */ (evt.target); | |
| const videoId = button.parentElement.parentElement.getAttribute('data-video-id'); | |
| this.dontDeleteIds.push(videoId); | |
| console.log({ dontDeleteIds: this.dontDeleteIds }); | |
| this.render(); | |
| }); | |
| } | |
| ); | |
| } | |
| /** | |
| * Extract various attributes out of a video history entry | |
| * @param {HTMLDivElement} videoEntry | |
| * @returns {Video} | |
| */ | |
| static parseVideoEntry(videoEntry) { | |
| const links = videoEntry.querySelectorAll('a'); | |
| /** @type {HTMLImageElement | null} */ | |
| const thumbnailImgTag = videoEntry.querySelector('a img'); | |
| const videoLink = links[0].href; | |
| const videoId = videoLink.match(/watch\?v=([^&]+)/)[1]; | |
| // Sometimes duration is missing. Promoted / ads only? Unlisted? Deleted? | |
| let durationString = ''; | |
| let minutes = 0; | |
| let seconds = 0; | |
| try { | |
| durationString = videoEntry.querySelector('[aria-label="Video duration"]').textContent; | |
| if (durationString) { | |
| [minutes, seconds] = durationString.split(':').map((str) => parseInt(str, 10)); | |
| } | |
| } catch (err) { | |
| // | |
| } | |
| return { | |
| title: links[0].textContent, | |
| author: links[1].textContent, | |
| thumbnailHref: thumbnailImgTag ? thumbnailImgTag.getAttribute('href') : undefined, | |
| thumbnailImgTag: thumbnailImgTag || undefined, | |
| videoLink, | |
| videoId, | |
| deleteButton: videoEntry.querySelector('button'), | |
| durationString, | |
| durationInSeconds: minutes * 60 + seconds, | |
| }; | |
| } | |
| static checkValidHistoryPage() { | |
| if (!/https{0,1}:\/\/myactivity.google.com\/.+\/product\/youtube$/.test(window.location.href)) { | |
| alert('Invalid YouTube History Page.'); | |
| throw new Error('Invalid YouTube History Page.'); | |
| } | |
| } | |
| /** @type {Filter} */ | |
| static ShortsFilter = (video) => { | |
| return video.durationInSeconds < 120; | |
| }; | |
| } | |
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.