YouTube 批量移除播放列表内视频(包括稍后再看、点赞过的视频)

清除稍后再看、点赞过的视频列表的视频(注:可能得重复开启尝试)

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name:en      YouTube Bulk Remove Videos from Playlists (including Watch Later & Liked Videos)
// @name         YouTube 批量移除播放列表内视频(包括稍后再看、点赞过的视频)
// @namespace   http://tampermonkey.net/
// @version      1.0.0
// @description:en  Clear videos from Watch Later or Liked Videos playlists (note: may require multiple runs)
// @description  清除稍后再看、点赞过的视频列表的视频(注:可能得重复开启尝试)
// @match        https://www.youtube.com/playlist?*
// @match        https://www.youtube.com/watch?*&list*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license      MIT
// @author kaesinol
// ==/UserScript==

(function () {
    'use strict';

    // ---------- i18n ----------
    const lang = (() => {
        try {
            const l = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
            return l.startsWith('zh') ? 'zh' : 'en';
        } catch (e) {
            return 'en';
        }
    })();

    const I18N = {
        en: {
            defaultText: 'Remove from playlist',
            menuRun: 'Run: Remove from playlist (manual)',
            menuSetText: 'Set: match text',
            menuSetDelay1: 'Set: delay before find (ms)',
            menuSetDelay2: 'Set: delay after click (ms)',
            promptSetText: 'Enter the menu text to match (partial text allowed):',
            promptDelay1: 'Milliseconds to wait after clicking menu before searching for listbox:',
            promptDelay2: 'Milliseconds to wait after clicking the remove item:',
            alertSaved: 'Saved.',
            alertNoItems: 'No menu buttons found. Page may not be loaded or structure changed.',
            alertCompleted: 'Operation completed.',
            alertCancelled: 'Cancelled.',
        },
        zh: {
            defaultText: '从播放列表中移除',
            menuRun: 'Run:从播放列表中移除(手动)',
            menuSetText: '设置:匹配文字',
            menuSetDelay1: '设置:点击后查找延迟(毫秒)',
            menuSetDelay2: '设置:点击后等待(毫秒)',
            promptSetText: '请输入要匹配的菜单文字(可部分匹配):',
            promptDelay1: '点击菜单后,等待多少毫秒再查找 listbox(建议 600-1500):',
            promptDelay2: '点击“移除”后等待多少毫秒(用于让请求/动画完成):',
            alertSaved: '已保存。',
            alertNoItems: '未找到菜单按钮。页面可能未加载或结构已变更。',
            alertCompleted: '操作完成。',
            alertCancelled: '已取消。',
        }
    };

    const t = I18N[lang];

    // ---------- keys & defaults ----------
    const KEY_TEXT = 'yt_remove_menu_text';
    const KEY_DELAY1 = 'yt_delay_before_find';
    const KEY_DELAY2 = 'yt_delay_after_click';
    const DEFAULT_TEXT = t.defaultText;
    const DEFAULT_DELAY1 = 600;
    const DEFAULT_DELAY2 = 800;

    // ---------- utilities ----------
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

    const getSetting = (key, def) => {
        try {
            const v = GM_getValue(key);
            return v === undefined ? def : v;
        } catch (e) {
            return def;
        }
    };

    const setSetting = (key, val) => {
        try { GM_setValue(key, val); } catch (e) { /* noop */ }
    };

    // ---------- menu registration helpers ----------
    let registeredIds = [];

    function unregisterAll() {
        if (!registeredIds || !registeredIds.length) return;
        try {
            if (typeof GM_unregisterMenuCommand === 'function') {
                for (const id of registeredIds) {
                    try { GM_unregisterMenuCommand(id); } catch (e) { /* ignore individual errors */ }
                }
            }
        } catch (e) {
            // ignore
        } finally {
            registeredIds = [];
        }
    }

    function registerMenus(processFn) {
        unregisterAll();
        try {
            const curText = getSetting(KEY_TEXT, DEFAULT_TEXT);
            const idRun = GM_registerMenuCommand(t.menuRun, processFn);
            const idSetText = GM_registerMenuCommand(`${t.menuSetText} (current: "${curText}")`, () => {
                const cur = getSetting(KEY_TEXT, DEFAULT_TEXT);
                const v = prompt(t.promptSetText, cur);
                if (v === null) { alert(t.alertCancelled); return; }
                setSetting(KEY_TEXT, v.trim());
                alert(t.alertSaved);
                // re-register menus so labels refresh
                registerMenus(processFn);
            });
            const idDelay1 = GM_registerMenuCommand(`${t.menuSetDelay1} (current: ${getSetting(KEY_DELAY1, DEFAULT_DELAY1)})`, () => {
                const cur = String(getSetting(KEY_DELAY1, DEFAULT_DELAY1));
                const v = prompt(t.promptDelay1, cur);
                if (v === null) { alert(t.alertCancelled); return; }
                const n = Math.max(0, parseInt(v) || 0);
                setSetting(KEY_DELAY1, n);
                alert(t.alertSaved);
                registerMenus(processFn);
            });
            const idDelay2 = GM_registerMenuCommand(`${t.menuSetDelay2} (current: ${getSetting(KEY_DELAY2, DEFAULT_DELAY2)})`, () => {
                const cur = String(getSetting(KEY_DELAY2, DEFAULT_DELAY2));
                const v = prompt(t.promptDelay2, cur);
                if (v === null) { alert(t.alertCancelled); return; }
                const n = Math.max(0, parseInt(v) || 0);
                setSetting(KEY_DELAY2, n);
                alert(t.alertSaved);
                registerMenus(processFn);
            });

            // store IDs if provided by manager
            registeredIds = [idRun, idSetText, idDelay1, idDelay2].filter(Boolean);
        } catch (e) {
            // GM_registerMenuCommand may be unavailable; fallback will be handled by caller
            registeredIds = [];
        }
    }

    // ---------- core worker ----------
    async function processAll() {
        const menuText = getSetting(KEY_TEXT, DEFAULT_TEXT);
        const delay1 = Number(getSetting(KEY_DELAY1, DEFAULT_DELAY1)) || DEFAULT_DELAY1;
        const delay2 = Number(getSetting(KEY_DELAY2, DEFAULT_DELAY2)) || DEFAULT_DELAY2;

        // strict selector (no fallback)
        const items = Array.from(document.querySelectorAll('#items ytd-menu-renderer button'));

        if (!items.length) {
            try { alert(t.alertNoItems); } catch (e) { }
            return;
        }

        for (const el of items) {
            try {
                // scroll element into view and focus it before clicking
                try {
                    el.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' });
                    el.focus && el.focus();
                } catch (e) {
                    // ignore scroll errors
                }
                // small extra wait to ensure visibility/render
                await sleep(150);

                // click the menu button (el is expected to be a <button>)
                el.click();
                await sleep(delay1);

                const boxes = Array.from(document.querySelectorAll('tp-yt-paper-listbox'));
                const box = boxes.find(b => b.innerText && b.innerText.includes(menuText));
                if (!box) {
                    continue;
                }

                const target = Array.from(box.querySelectorAll('*')).find(n => n.innerText && n.innerText.includes(menuText));
                if (!target) continue;

                // ensure the target is visible as well
                try { target.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'nearest' }); } catch (e) { }
                target.click();
                await sleep(delay2);
            } catch (e) {
                // swallow errors silently
            }
        }

        try { alert(t.alertCompleted); } catch (e) { }
    }

    // ---------- init: register menus or expose fallback ----------
    try {
        registerMenus(processAll);
    } catch (e) {
        // in case menu registration fails, expose run for manual use from console
        try { window.YTRemoveAssistant = { run: processAll }; } catch (err) { /* noop */ }
    }

    // do not auto-run
})();