Files
playground/yt-history-remover.user.js
T
2026-07-24 15:58:31 +02:00

183 lines
6.7 KiB
JavaScript

// ==UserScript==
// @name YouTube History Backspace Remover
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Hover over a video in YouTube watch history and press Backspace to remove it
// @author You
// @match https://www.youtube.com/feed/history
// @match https://www.youtube.com/feed/history/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// videoId → feedbackToken, populated from ytInitialData
let tokenMap = {};
let hoveredLink = null;
let isProcessing = false;
// --- Token extraction from ytInitialData ---
function extractTokens(data) {
const tokens = {};
(function walk(obj) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { obj.forEach(walk); return; }
if (obj.listItemViewModel) {
const lv = obj.listItemViewModel;
const title = lv.title?.content || '';
if (title.toLowerCase().includes('remove from watch history')) {
const ep = lv.rendererContext?.commandContext?.onTap?.innertubeCommand?.feedbackEndpoint;
if (ep?.feedbackToken && ep?.contentId) {
tokens[ep.contentId] = ep.feedbackToken;
}
}
}
for (const v of Object.values(obj)) walk(v);
})(data);
return tokens;
}
function refreshTokens() {
if (!window.ytInitialData) return;
const found = extractTokens(window.ytInitialData);
const n = Object.keys(found).length;
if (n > 0) {
tokenMap = Object.assign(tokenMap, found);
console.log(`[YT History Remover] ${n} tokens loaded (total: ${Object.keys(tokenMap).length})`);
}
}
// --- SAPISIDHASH for Authorization header ---
async function buildAuthHeader() {
const sapisid = document.cookie.match(/(?:^|;\s*)SAPISID=([^;]+)/)?.[1];
if (!sapisid) return null;
const ts = Math.floor(Date.now() / 1000);
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(`${ts} ${sapisid} https://www.youtube.com`));
const hex = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
return `SAPISIDHASH ${ts}_${hex}`;
}
// --- API call to remove from history ---
async function callFeedbackApi(feedbackToken) {
const ctx = window.ytcfg?.data_?.INNERTUBE_CONTEXT;
if (!ctx) { console.log('[YT History Remover] No INNERTUBE_CONTEXT'); return false; }
const auth = await buildAuthHeader();
const headers = {
'Content-Type': 'application/json',
'X-YouTube-Client-Name': '1',
'X-YouTube-Client-Version': window.ytcfg?.data_?.INNERTUBE_CLIENT_VERSION || '',
'X-Origin': 'https://www.youtube.com',
};
if (auth) headers['Authorization'] = auth;
const res = await fetch('/youtubei/v1/feedback?prettyPrint=false', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ context: ctx, feedbackTokens: [feedbackToken] }),
});
if (!res.ok) { console.log(`[YT History Remover] HTTP ${res.status}`); return false; }
const json = await res.json();
return json.feedbackResponses?.[0]?.isProcessed === true;
}
// --- DOM helpers ---
function getVideoId(href) {
try {
const url = new URL(href);
if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null;
return url.searchParams.get('v');
} catch { return null; }
}
function findVideoLink(el) {
while (el && el !== document.body) {
if (el.tagName === 'A' && el.href && (el.href.includes('/watch?') || el.href.includes('/shorts/')))
return el;
el = el.parentElement;
}
return null;
}
function hideInDOM(videoId) {
for (const sel of [`a[href*="v=${videoId}"]`, `a[href*="/shorts/${videoId}"]`]) {
const link = document.querySelector(sel);
if (!link) continue;
const row = link.closest('yt-lockup-view-model')
|| link.closest('ytm-shorts-lockup-view-model')
|| link.closest('ytd-item-section-renderer');
if (row) { row.style.display = 'none'; return; }
}
}
// --- Main remove action ---
async function tryRemove() {
if (isProcessing || !hoveredLink) return;
isProcessing = true;
try {
const videoId = getVideoId(hoveredLink.href);
if (!videoId) return;
const token = tokenMap[videoId];
if (!token) {
console.log(`[YT History Remover] No token for ${videoId}. Tokens available: ${Object.keys(tokenMap).length}`);
return;
}
const ok = await callFeedbackApi(token);
if (ok) {
hideInDOM(videoId);
delete tokenMap[videoId];
hoveredLink = null;
console.log(`[YT History Remover] Removed ${videoId}`);
} else {
console.log(`[YT History Remover] API returned not-processed for ${videoId}`);
}
} catch (e) {
console.error('[YT History Remover]', e);
} finally {
isProcessing = false;
}
}
// --- Event listeners ---
document.addEventListener('mouseover', e => {
if (isProcessing) return;
const link = findVideoLink(e.target);
if (link) hoveredLink = link;
});
document.addEventListener('keydown', e => {
if (e.key !== 'Backspace' || !hoveredLink || isProcessing) return;
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;
e.preventDefault();
tryRemove();
});
document.addEventListener('mouseout', e => {
if (isProcessing || !hoveredLink) return;
const rt = e.relatedTarget;
if (!rt || !hoveredLink.contains(rt)) hoveredLink = null;
});
// Re-extract when YouTube finishes a SPA navigation
document.addEventListener('yt-navigate-finish', () => setTimeout(refreshTokens, 500));
// Polling: re-read ytInitialData (updated by YouTube on SPA nav) and clean stale refs
setInterval(() => {
refreshTokens();
if (hoveredLink && !document.contains(hoveredLink)) hoveredLink = null;
}, 2000);
// Initial extraction
refreshTokens();
console.log(`[YT History Remover] Ready. Tokens loaded: ${Object.keys(tokenMap).length}`);
})();