Forked from DinoChiesa/delete-watch-history-shorts.console.js
Last active
January 4, 2026 15:01
-
-
Save mguterl/bae46f913ec269c712951d52706d36ea to your computer and use it in GitHub Desktop.
Remove video shorts from YT Watch History
This file contains hidden or 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
| // YouTube Watch History Cleaner | |
| // Paste into console at: https://myactivity.google.com/product/youtube | |
| // orig: https://gist.github.com/miketromba/334282421c4784d7d9a191ca25095c09 | |
| // ============================================================================ | |
| // CONFIGURATION - Edit this section to customize what gets deleted | |
| // ============================================================================ | |
| const CONFIG = { | |
| // DRY RUN MODE: When true, logs what would be deleted without actually deleting | |
| // Set to false when you're ready to actually delete | |
| dryRun: false, | |
| // How to combine filters: | |
| // 'any' = delete if ANY filter matches (OR logic) | |
| // 'all' = delete only if ALL enabled filters match (AND logic) | |
| filterMode: 'any', | |
| // Timing settings (milliseconds) | |
| cycleIntervalMs: 1800, | |
| confirmCheckIntervalMs: 2000, | |
| // ------------------------------------------------------------------------- | |
| // FILTERS - Enable/disable and configure each filter | |
| // ------------------------------------------------------------------------- | |
| filters: { | |
| // Delete videos shorter than a certain duration (i.e., Shorts) | |
| shortVideos: { | |
| enabled: true, | |
| maxDurationMinutes: 1.5, // Videos under 1:30 are considered "shorts" | |
| }, | |
| // Delete videos watched during certain hours (e.g., sleep time) | |
| timeOfDay: { | |
| enabled: true, | |
| // 24-hour format. This example targets 10 PM - 6 AM | |
| startHour: 22, // Start of deletion window (inclusive) | |
| endHour: 6, // End of deletion window (exclusive) | |
| // Note: Handles overnight ranges correctly (23:00 -> 06:00) | |
| }, | |
| // Delete videos from specific channels | |
| channels: { | |
| enabled: false, | |
| names: [ | |
| // 'SomeChannelName', | |
| // 'AnotherChannel', | |
| ], | |
| }, | |
| // Delete videos whose title contains certain keywords | |
| keywords: { | |
| enabled: true, | |
| terms: [ | |
| 'biden', | |
| 'democrat', | |
| 'doj', | |
| 'epstein', | |
| 'gop', | |
| 'gordon', | |
| 'nightmares', | |
| 'ramsay', | |
| 'republican', | |
| 'trump', | |
| ], | |
| caseSensitive: false, | |
| }, | |
| // Delete ads (items without a duration) | |
| ads: { | |
| enabled: false, | |
| }, | |
| }, | |
| }; | |
| // ============================================================================ | |
| // IMPLEMENTATION - You probably don't need to edit below this line | |
| // ============================================================================ | |
| const state = { | |
| alreadyProcessed: [], | |
| deletedCount: 0, | |
| skippedCount: 0, | |
| running: true, | |
| }; | |
| // --- Utility functions --- | |
| const log = (msg, ...args) => console.log(`[yt-cleaner] ${msg}`, ...args); | |
| const warn = (msg, ...args) => console.warn(`[yt-cleaner] ${msg}`, ...args); | |
| const getVideoIdentifiers = (el) => { | |
| const anchors = [...el.getElementsByTagName('a')]; | |
| const [videoName, channelName] = anchors.map(a => a.textContent.trim()); | |
| return { videoName, channelName }; | |
| }; | |
| const getUniqueKey = (el) => { | |
| const { videoName, channelName } = getVideoIdentifiers(el); | |
| return `${videoName}|${channelName}`; | |
| }; | |
| const wasAlreadyProcessed = (el) => state.alreadyProcessed.includes(getUniqueKey(el)); | |
| // --- Duration parsing --- | |
| const getDurationString = (el) => { | |
| const durationEl = el.querySelector('[aria-label="Video duration"]'); | |
| return durationEl?.textContent?.trim() || null; | |
| }; | |
| const getDurationMs = (el) => { | |
| const dString = getDurationString(el); | |
| if (!dString) return null; // No duration found (might be an ad) | |
| const parts = dString.split(':'); | |
| if (parts.length > 2) { | |
| // Over an hour: HH:MM:SS | |
| const [hrs, mins, secs] = parts.map(n => parseInt(n, 10)); | |
| return ((hrs * 60 + mins) * 60 + secs) * 1000; | |
| } | |
| // Under an hour: MM:SS | |
| const [mins, secs] = parts.map(n => parseInt(n, 10)); | |
| return (mins * 60 + secs) * 1000; | |
| }; | |
| // --- Timestamp parsing --- | |
| const parseActivityTime = (el) => { | |
| // Try to find timestamp text in the element | |
| const fullText = el.textContent || ''; | |
| // Match patterns like "Today 11:42 PM", "Yesterday 2:15 AM", "Dec 25 3:30 PM" | |
| const timeMatch = fullText.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); | |
| if (!timeMatch) return null; | |
| let [, hourStr, minStr, ampm] = timeMatch; | |
| let hour = parseInt(hourStr, 10); | |
| const minute = parseInt(minStr, 10); | |
| // Convert to 24-hour format | |
| if (ampm.toUpperCase() === 'PM' && hour !== 12) hour += 12; | |
| if (ampm.toUpperCase() === 'AM' && hour === 12) hour = 0; | |
| return { hour, minute }; | |
| }; | |
| // --- Filter implementations --- | |
| const filters = { | |
| shortVideos: (el) => { | |
| const cfg = CONFIG.filters.shortVideos; | |
| const durationMs = getDurationMs(el); | |
| if (durationMs === null) return false; | |
| const maxMs = cfg.maxDurationMinutes * 60 * 1000; | |
| return durationMs < maxMs; | |
| }, | |
| timeOfDay: (el) => { | |
| const cfg = CONFIG.filters.timeOfDay; | |
| const time = parseActivityTime(el); | |
| if (!time) return false; | |
| const { hour } = time; | |
| const { startHour, endHour } = cfg; | |
| if (startHour > endHour) { | |
| // Overnight: matches if hour >= start OR hour < end | |
| return hour >= startHour || hour < endHour; | |
| } else { | |
| // Same-day range: matches if hour >= start AND hour < end | |
| return hour >= startHour && hour < endHour; | |
| } | |
| }, | |
| channels: (el) => { | |
| const cfg = CONFIG.filters.channels; | |
| const { channelName } = getVideoIdentifiers(el); | |
| return cfg.names.some(name => | |
| channelName.toLowerCase().includes(name.toLowerCase()) | |
| ); | |
| }, | |
| keywords: (el) => { | |
| const cfg = CONFIG.filters.keywords; | |
| const { videoName } = getVideoIdentifiers(el); | |
| const title = cfg.caseSensitive ? videoName : videoName.toLowerCase(); | |
| return cfg.terms.some(term => { | |
| const needle = cfg.caseSensitive ? term : term.toLowerCase(); | |
| return title.includes(needle); | |
| }); | |
| }, | |
| ads: (el) => { | |
| return getDurationString(el) === null; | |
| }, | |
| }; | |
| // --- Main filter logic --- | |
| const shouldDelete = (el) => { | |
| const enabledFilters = Object.entries(filters) | |
| .filter(([name]) => CONFIG.filters[name]?.enabled); | |
| if (enabledFilters.length === 0) { | |
| warn('No filters enabled! Enable at least one filter in CONFIG.'); | |
| return false; | |
| } | |
| const results = enabledFilters.map(([name, fn]) => ({ | |
| name, | |
| matches: fn(el), | |
| })); | |
| if (CONFIG.filterMode === 'all') { | |
| return results.every(r => r.matches); | |
| } else { | |
| return results.some(r => r.matches); | |
| } | |
| }; | |
| const getMatchingFilters = (el) => { | |
| return Object.entries(filters) | |
| .filter(([name]) => CONFIG.filters[name]?.enabled) | |
| .filter(([, fn]) => fn(el)) | |
| .map(([name]) => name); | |
| }; | |
| // --- Deletion logic --- | |
| const getNextItemToDelete = () => { | |
| const items = document.querySelectorAll('div[role="listitem"]'); | |
| for (const item of items) { | |
| if (wasAlreadyProcessed(item)) continue; | |
| if (shouldDelete(item)) return item; | |
| } | |
| return null; | |
| }; | |
| const deleteNext = async () => { | |
| if (!state.running) return; | |
| const item = getNextItemToDelete(); | |
| if (!item) { | |
| log(`No more matching items found. Deleted: ${state.deletedCount}, Skipped: ${state.skippedCount}`); | |
| log('Scroll down to load more history, or the script will check again in 10s...'); | |
| setTimeout(deleteNext, 10000); | |
| return; | |
| } | |
| const { videoName, channelName } = getVideoIdentifiers(item); | |
| const durationStr = getDurationString(item) || 'no duration'; | |
| const timeInfo = parseActivityTime(item); | |
| const timeStr = timeInfo ? `${timeInfo.hour}:${String(timeInfo.minute).padStart(2, '0')}` : 'unknown time'; | |
| const matchedFilters = getMatchingFilters(item); | |
| state.alreadyProcessed.push(getUniqueKey(item)); | |
| if (CONFIG.dryRun) { | |
| log(`[DRY RUN] Would delete: "${videoName}" by ${channelName} (${durationStr}, watched at ${timeStr}) - matched: ${matchedFilters.join(', ')}`); | |
| state.skippedCount++; | |
| setTimeout(deleteNext, 500); // Faster in dry-run mode | |
| return; | |
| } | |
| // Actually delete | |
| log(`Deleting: "${videoName}" by ${channelName} (${durationStr}, ${timeStr}) - matched: ${matchedFilters.join(', ')}`); | |
| const deleteButton = item.getElementsByTagName('button')[0]; | |
| if (!deleteButton) { | |
| warn('Could not find delete button, skipping...'); | |
| setTimeout(deleteNext, CONFIG.cycleIntervalMs); | |
| return; | |
| } | |
| deleteButton.click(); | |
| state.deletedCount++; | |
| // Handle confirmation dialog (appears on first delete) | |
| setTimeout(() => { | |
| const menu = item.querySelector('[aria-label="Activity options menu"]'); | |
| if (menu) { | |
| const confirmBtn = menu.querySelector('[aria-label="Delete activity item"]'); | |
| confirmBtn?.click(); | |
| } | |
| setTimeout(deleteNext, CONFIG.cycleIntervalMs); | |
| }, CONFIG.confirmCheckIntervalMs); | |
| }; | |
| // --- Control functions --- | |
| window.ytCleanerStop = () => { | |
| state.running = false; | |
| log('Stopped. Call ytCleanerStart() to resume.'); | |
| }; | |
| window.ytCleanerStart = () => { | |
| state.running = true; | |
| log('Started.'); | |
| deleteNext(); | |
| }; | |
| window.ytCleanerStatus = () => { | |
| log(`Status: ${state.running ? 'running' : 'stopped'}, Deleted: ${state.deletedCount}, Processed: ${state.alreadyProcessed.length}`); | |
| log(`Dry run: ${CONFIG.dryRun}`); | |
| log('Enabled filters:', Object.entries(CONFIG.filters).filter(([, v]) => v.enabled).map(([k]) => k).join(', ')); | |
| }; | |
| // --- Startup --- | |
| log('YouTube Watch History Cleaner loaded'); | |
| log(`Mode: ${CONFIG.dryRun ? 'DRY RUN (no deletions)' : 'LIVE (will delete!)'}`); | |
| log(`Filter mode: ${CONFIG.filterMode} (${CONFIG.filterMode === 'any' ? 'delete if ANY filter matches' : 'delete only if ALL filters match'})`); | |
| log('Enabled filters:', Object.entries(CONFIG.filters).filter(([, v]) => v.enabled).map(([k]) => k).join(', ') || 'none!'); | |
| log('---'); | |
| log('Controls: ytCleanerStop(), ytCleanerStart(), ytCleanerStatus()'); | |
| log('---'); | |
| deleteNext(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment