Skip to content

Instantly share code, notes, and snippets.

@joshuatz
Last active January 13, 2024 11:28
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joshuatz/8dd1c5c3714b9ab615853a0c8c163626 to your computer and use it in GitHub Desktop.
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
(() => {
// 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
);
})();
// @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
Copy link

Hey, it's not working ?

@joshuatz
Copy link
Author

@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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment