Last active
May 12, 2024 22:54
-
-
Save joshuatz/8dd1c5c3714b9ab615853a0c8c163626 to your computer and use it in GitHub Desktop.
Script to clean up YouTube history and bulk-delete videos that mess up the recommendation algorithm
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(() => { | |
// With default shorts filter - dry run mode | |
new YouTubeHistoryCleaner(undefined, true); | |
// dryRunMode = off (actually delete stuff) | |
new YouTubeHistoryCleaner(undefined, false); | |
// With custom filter, preserving history for certain accounts | |
const approvedAuthors = [ | |
'Mayaland', | |
'Hideaki Utsumi', | |
]; | |
new YouTubeHistoryCleaner( | |
[ | |
YouTubeHistoryCleaner.ShortsFilter, | |
(video) => { | |
return !approvedAuthors.includes(video.author); | |
}, | |
], | |
false | |
); | |
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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; | |
}; | |
} | |
@skabdullah9 I'm not seeing any issues on my end - can you copy and paste any errors you are seeing in your browser console so I can investigate?
@skabdullah9 I'm not seeing any issues on my end - can you copy and paste any errors you are seeing in your browser console so I can investigate?
its just showing undefined, do i have to make any changes to code before entering it
its just showing undefined, do i have to make any changes to code before entering it
Yes - it sounds like you didn't actually execute the scraper with something like new YouTubeHistoryCleaner(undefined, false);
(see run_examples.js
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey, it's not working ?