183 lines
6.7 KiB
JavaScript
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}`);
|
|
})();
|