chore: Update everything
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// ==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}`);
|
||||
})();
|
||||
Reference in New Issue
Block a user