B站动态批量删除助手

这是一个帮助B站用户高效管理个人动态的脚本,支持多种类型动态的批量删除操作。

// ==UserScript==
// @name         B站动态批量删除助手
// @version      0.28
// @description  这是一个帮助B站用户高效管理个人动态的脚本,支持多种类型动态的批量删除操作。
// @author       梦把我
// @match        https://space.bilibili.com/*
// @match        http://space.bilibili.com/*
// @require      https://greasyfork.org/scripts/38220-mscststs-tools/code/MSCSTSTS-TOOLS.js?version=713767
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js
// @icon         https://static.hdslb.com/images/favicon.ico
// @namespace https://greasyfork.org/users/1383389
// @license      MIT
// @grant        GM_addStyle
// ==/UserScript==
(function () {
    'use strict';
 
    const uid = window.location.pathname.split("/")[1];
 
    function getUserCSRF() {
        return document.cookie.split("; ").find(row => row.startsWith("bili_jct="))?.split("=")[1];
    }
 
    const csrfToken = getUserCSRF();
 
    class Api {
        constructor() { }
 
        async spaceHistory(offset = 0) { // 获取个人动态
            return this.retryOn429(() => this._api(
                `https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history?visitor_uid=${uid}&host_uid=${uid}&offset_dynamic_id=${offset}`,
                {}, "get"
            ));
        }
 
        async removeDynamic(id) { // 删除动态
            return this._api(
                "https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/rm_dynamic",
                { dynamic_id: id, csrf_token: csrfToken }
            );
        }
 
        async _api(url, data, method = "post") { // 通用请求
            return axios({
                url,
                method,
                data: this.transformRequest(data),
                withCredentials: true,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            }).then(res => res.data);
        }
 
        transformRequest(data) { // 转换请求参数
            return Object.entries(data).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
        }
 
        async fetchJsonp(url) { // jsonp请求
            return fetchJsonp(url).then(res => res.json());
        }
 
        async retryOn429(func, retries = 5, delay = 100) { // 出现429错误时冷却100ms重试,出现412错误时提示并退出
            while (retries > 0) {
                try {
                    return await func();
                } catch (err) {
                    if (err.response && err.response.status === 429) {
                        await this.sleep(delay);
                        retries--;
                    } else if (err.response && err.response.status === 412) {
                        alert('由于请求过于频繁,IP暂时被ban,请更换IP或稍后再试。');
                        throw new Error('IP blocked, please retry later.');
                    } else {
                        throw err;
                    }
                }
            }
            throw new Error('Too many retries, request failed.');
        }
 
        sleep(ms) { // 睡眠
            return new Promise(resolve => setTimeout(resolve, ms));
        }
    }
 
    const api = new Api();
    const buttons = [
        ".onlyDeleteRepost", 
        ".deleteVideo", 
        ".deleteImage", 
        ".deleteText", 
        ".deleteArticle", 
        ".deleteShortVideo"
    ];
    let logNode;
 
    // 添加确认状态管理
    const confirmStates = {
        deleteStates: {},
        resetTimer: null
    };
 
    // 获取当前URL中的UID
    function getCurrentUID() {
        const pathParts = window.location.pathname.split('/');
        return pathParts[1] || '';
    }

    // 获取自己的UID(通过访问space.bilibili.com)
    async function getMyUID() {
        try {
            const response = await fetch('https://space.bilibili.com/', {
                credentials: 'include'  // 确保携带cookie
            });
            // 获取重定向后的URL
            const redirectUrl = response.url;
            const uid = redirectUrl.split('/').pop();
            return uid;
        } catch (error) {
            console.error('获取用户UID失败:', error);
            return null;
        }
    }
 
    async function init() {
        try {
            // 等待页面加载完成
            await new Promise(resolve => setTimeout(resolve, 500));

            // 获取当前页面UID和自己的UID
            const currentUID = getCurrentUID();
            const myUID = await getMyUID();

            // 判断是否为自己的空间
            if (!currentUID || !myUID || currentUID !== myUID) {
                console.log('当前不是自己的个人动态页面,脚本未启用');
                return;
            }

            // 创建控制面板节点
            const node = createControlPanel();
            
            // 尝试插入到新版或旧版界面
            try {
                // 先尝试获取新版界面的位置
                const newVersionContainer = document.querySelector("#app > main > div.space-dynamic > div.space-dynamic__right");
                if (newVersionContainer) {
                    const firstChild = newVersionContainer.querySelector("div:nth-child(1)");
                    if (firstChild) {
                        newVersionContainer.insertBefore(node, firstChild);
                    } else {
                        newVersionContainer.appendChild(node);
                    }
                    console.log('成功插入到新版界面');
                } else {
                    // 如果找不到新版界面,尝试旧版界面
                    const oldVersionContainer = document.querySelector("#page-dynamic .col-2");
                    if (oldVersionContainer) {
                        oldVersionContainer.appendChild(node);
                        console.log('成功插入到旧版界面');
                    } else {
                        console.error('无法找到合适的插入位置');
                        return;
                    }
                }

                // 设置事件监听
                setEventListeners();
                
                // 设置教程链接
                document.querySelector('.tutorial-btn').href = 'https://www.bilibili.com/video/BV13NBnYyEML/';
                
                // 添加样式
                addConfirmationStyles();
                
            } catch (error) {
                console.error('插入控制面板失败:', error);
            }

        } catch (error) {
            console.error('验证用户身份失败:', error);
        }
    }
 
    function createControlPanel() {
        const node = document.createElement("div");
        node.className = "msc_panel";
        node.innerHTML = `
            <div class="inner">
                <div class="panel-section quick-actions">
                    <h3>快捷操作</h3>
                    <div class="button-group">
                        <button class="onlyDeleteRepost">删除转发动态</button>
                        <button class="deleteVideo">删除视频动态</button>
                        <button class="deleteImage">删除图片动态</button>
                        <button class="deleteText">删除文字动态</button>
                        <button class="deleteArticle">删除专栏动态</button>
                        <button class="deleteShortVideo">删除小视频动态</button>
                    </div>
                </div>

                <div class="panel-section other-actions">
                    <h3>其他</h3>
                    <div class="button-group">
                        <a href="#" class="tutorial-btn" target="_blank">使用视频教程</a>
                    </div>
                </div>

                <div class="panel-section pin-settings">
                    <h3>动态保留设置</h3>
                    <div class="setting-group">
                        <label class="switch">
                            <input type="checkbox" id="preservePinned">
                            <span class="slider round"></span>
                            <span class="label">保留指定的动态</span>
                        </label>
                        <div class="preserve-contents">
                            <div class="preserve-content-item">
                                <input type="text" class="preserve-content" placeholder="输入要保留的动态文字内容">
                                <small class="tip">输入动态中的部分内容即可,建议复制完整内容以提高匹配准确度</small>
                            </div>
                            <button class="add-preserve-content" title="添加更多保留内容">
                                <span>+</span>
                            </button>
                        </div>
                        
                        <div class="most-liked-setting">
                            <label class="switch">
                                <input type="checkbox" id="preserveMostLiked">
                                <span class="slider round"></span>
                                <span class="label">保留点赞最高的动态</span>
                            </label>
                            <div class="most-liked-count">
                                <input type="number" id="mostLikedCount" value="3" min="1" disabled>
                                <small class="tip">设置要保留的点赞最高动态数量</small>
                            </div>
                        </div>
                    </div>
                </div>
            </div>`;
        return node;
    }
 
    function setEventListeners() {
        document.querySelector(".onlyDeleteRepost").addEventListener("click", () => handleConfirmation("onlyDeleteRepost", () => handleDelete(false)));
        document.querySelector(".deleteVideo").addEventListener("click", () => handleConfirmation("deleteVideo", () => handleDeleteByType(8)));
        document.querySelector(".deleteImage").addEventListener("click", () => handleConfirmation("deleteImage", () => handleDeleteByType(2)));
        document.querySelector(".deleteText").addEventListener("click", () => handleConfirmation("deleteText", () => handleDeleteByType(4)));
        document.querySelector(".deleteArticle").addEventListener("click", () => handleConfirmation("deleteArticle", () => handleDeleteByType(64)));
        document.querySelector(".deleteShortVideo").addEventListener("click", () => handleConfirmation("deleteShortVideo", () => handleDeleteByType(16)));
        
        // 添加新的保留内容输入框
        document.querySelector('.add-preserve-content').addEventListener('click', addPreserveContentInput);
        
        // 添加点赞设置相关的事件监听
        const preserveMostLikedCheckbox = document.querySelector('#preserveMostLiked');
        const mostLikedCountInput = document.querySelector('#mostLikedCount');
        
        preserveMostLikedCheckbox.addEventListener('change', (e) => {
            mostLikedCountInput.disabled = !e.target.checked;
        });
        
        mostLikedCountInput.addEventListener('input', (e) => {
            const value = parseInt(e.target.value);
            if (value < 1) e.target.value = 1;
        });
    }
 
    async function handleDelete(deleteLottery) { // 删除参数 unfollow
        disableAll();
        let deleteCount = 0; // 删除计数
        let hasMore = true; // 是否还有更多动态
        let offset = 0; // 动态偏移量
 
        while (hasMore) {
            const { data } = await api.spaceHistory(offset);
            hasMore = data.has_more;
 
            for (const card of data.cards) {
                offset = card.desc.dynamic_id_str;
 
                if (card.desc.orig_dy_id != 0) { // 如果是转发动态
                    try {
                        const content = JSON.parse(card.card);
                        const content2 = JSON.parse(content.origin_extend_json);
 
                        if (!deleteLottery || content2.lott) { // 如果"仅删除抽奖"为假,或判断为抽奖动态
                            const rm = await api.removeDynamic(card.desc.dynamic_id_str);
                            if (rm.code === 0) deleteCount++;
                            else throw new Error("删除出错");
                        }
                        await api.sleep(50);
                        log(`已删除 ${deleteCount} 条动态`);
                    } catch (e) {
                        console.error(e);
                        break;
                    }
                }
            }
        }
        enableAll();
    }
 
    function disableAll() {
        console.log('start');
        buttons.forEach(btn => {
            const button = document.querySelector(btn);
            button.disabled = true;
            resetButtonState(btn.substring(1)); // 移除开头的点号
        });
        confirmStates.deleteStates = {}; // 清除所有确认状态
    }
 
    function enableAll() {
        console.log('done');
        buttons.forEach(btn => {
            const button = document.querySelector(btn);
            if (button) {
                button.disabled = false;
                resetButtonState(btn.substring(1));
            }
        });
        confirmStates.deleteStates = {};
        log('操作已完成!', true);
    }
 
    let currentPopup = null;
    let currentTimer = null;

    function log(message, autoRefresh = false) {
        // 如果存在之前的弹窗和定时器,先清除
        if (currentPopup) {
            currentPopup.remove();
            clearTimeout(currentTimer);
        }

        // 创建新的弹窗
        const popup = document.createElement('div');
        popup.className = 'log-popup';
        popup.textContent = message;
        document.body.appendChild(popup);
        currentPopup = popup;

        if (autoRefresh) {
            let countdown = 3;
            const updateCountdown = () => {
                popup.textContent = `${message} (${countdown}秒后自动刷新)`;
                countdown--;
                if (countdown < 0) {
                    window.location.reload();
                } else {
                    currentTimer = setTimeout(updateCountdown, 1000);
                }
            };
            updateCountdown();
        } else {
            // 3秒后自动隐藏弹窗
            currentTimer = setTimeout(() => {
                popup.classList.add('hide');
                setTimeout(() => popup.remove(), 300);
            }, 3000);
        }
    }
 
    async function handleDeleteByType(targetType) {
        const preservePinned = document.querySelector('#preservePinned').checked;
        const preserveMostLiked = document.querySelector('#preserveMostLiked').checked;
        const mostLikedCount = parseInt(document.querySelector('#mostLikedCount').value);
        const preserveContents = Array.from(document.querySelectorAll('.preserve-content'))
            .map(input => input.value.trim())
            .filter(value => value !== '');
        
        if (preservePinned && preserveContents.length === 0) {
            alert('检测到开启保留动态功能,请至少输入一个要保留的动态内容');
            return;
        }

        try {
            disableAll();
            let deleteCount = 0;
            let hasMore = true;
            let offset = 0;
            let allDynamics = [];

            // 首先收集所有动态
            while (hasMore) {
                const { data } = await api.spaceHistory(offset);
                hasMore = data.has_more;
                
                for (const card of data.cards) {
                    if (card.desc.type === targetType) {
                        allDynamics.push({
                            id: card.desc.dynamic_id_str,
                            likes: card.desc.like,
                            content: JSON.parse(card.card)?.item?.content || ''
                        });
                    }
                    offset = card.desc.dynamic_id_str;
                }
            }

            // 如果需要保留点赞最高的动态
            let preservedIds = new Set();
            if (preserveMostLiked && mostLikedCount > 0) {
                const topLiked = allDynamics
                    .sort((a, b) => b.likes - a.likes)
                    .slice(0, mostLikedCount);
                preservedIds = new Set(topLiked.map(d => d.id));
            }

            // 执行删除操作
            for (const dynamic of allDynamics) {
                // 跳过需要保留的动态
                if (preservedIds.has(dynamic.id)) {
                    console.log('保留点赞数最高的动态:', dynamic.id, '点赞数:', dynamic.likes);
                    continue;
                }

                // 检查是否包含需要保留的内容
                if (preservePinned && preserveContents.length > 0) {
                    if (preserveContents.some(content => dynamic.content.includes(content))) {
                        console.log('跳过包含保留内容的动态:', dynamic.content);
                        continue;
                    }
                }

                try {
                    const rm = await api.removeDynamic(dynamic.id);
                    if (rm.code === 0) deleteCount++;
                    await api.sleep(50);
                    log(`已删除 ${deleteCount} 条类型为 ${targetType} 的动态`);
                } catch (e) {
                    console.error(e);
                    break;
                }
            }
        } catch (error) {
            console.error('删除操作执行出错:', error);
        } finally {
            enableAll();
        }
    }
 
    // 添加确认处理函数
    function handleConfirmation(buttonId, callback) {
        const button = document.querySelector(`.${buttonId}`);
        if (!button) return;
        
        const originalText = button.textContent;
        
        // 如果是首次点击
        if (!confirmStates.deleteStates[buttonId]) {
            // 设置确认状态
            confirmStates.deleteStates[buttonId] = true;
            
            // 修改按钮文字
            button.textContent = "确认删除?";
            button.style.backgroundColor = "#ff6b6b";
            
            // 添加闪烁动画
            button.style.animation = "buttonBlink 1s infinite";
            
            // 5秒后重置状态
            setTimeout(() => {
                resetButtonState(buttonId);
            }, 5000);
            
            // 显示提示
            log("请再次点击确认删除操作");
        } else {
            try {
                // 第二次点击,执行删除
                resetButtonState(buttonId);
                callback();
            } catch (error) {
                console.error('执行删除操作时出错:', error);
                resetButtonState(buttonId);
                enableAll();
                log('操作执行出错,请重试');
            }
        }
    }
 
    // 重置按钮状态
    function resetButtonState(buttonId) {
        const button = document.querySelector(`.${buttonId}`);
        if (!button) return;
        
        // 重置确认状态
        confirmStates.deleteStates[buttonId] = false;
        
        // 重置按钮状态
        button.disabled = false;
        button.textContent = getOriginalButtonText(buttonId);
        button.style.backgroundColor = "";
        button.style.animation = "";
        
        // 清除可能存在的定时器
        if (confirmStates.resetTimer) {
            clearTimeout(confirmStates.resetTimer);
            confirmStates.resetTimer = null;
        }
    }
 
    // 获取按钮原始文字
    function getOriginalButtonText(buttonId) {
        const textMap = {
            'onlyDeleteRepost': '删除转发动态',
            'deleteVideo': '删除视频动态',
            'deleteImage': '删除图片动态',
            'deleteText': '删除文字动态',
            'deleteArticle': '删除专栏动态',
            'deleteShortVideo': '删除小视频动态'
        };
        return textMap[buttonId] || '删除';
    }
 
    // 添加闪烁动画样式
    function addConfirmationStyles() {
        // 使用 GM_addStyle 替代直接创建 style 标签
        const styles = `
            .msc_panel {
                max-width: 100%;
                margin: 0 0 20px 0;
                padding: 20px;
                background: #fff;
                border-radius: 8px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
            }

            .panel-section {
                margin-bottom: 24px;
                padding-bottom: 20px;
                border-bottom: 1px solid #eee;
            }

            .panel-section:last-child {
                border-bottom: none;
                margin-bottom: 0;
            }

            .panel-section h3 {
                font-size: 16px;
                color: #18191c;
                margin-bottom: 16px;
                font-weight: 500;
            }

            .type-table table {
                width: 100%;
                border-collapse: collapse;
                margin: 10px 0;
                font-size: 14px;
            }

            .type-table th, .type-table td {
                padding: 8px;
                text-align: center;
                border: 1px solid #eee;
            }

            .type-table th {
                background: #f6f7f8;
            }

            .input-group {
                display: flex;
                gap: 10px;
                margin-bottom: 10px;
            }

            .type-input {
                flex: 1;
                padding: 8px 12px;
                border: 1px solid #ddd;
                border-radius: 4px;
                font-size: 14px;
            }

            .button-group {
                display: flex;
                flex-wrap: wrap;
                gap: 10px;
            }

            .msc_panel button {
                padding: 8px 16px;
                border-radius: 4px;
                border: 1px solid #ddd;
                background: #fff;
                cursor: pointer;
                font-size: 14px;
                transition: all 0.2s;
            }

            .msc_panel button:hover {
                background: #f6f7f8;
            }

            .msc_panel button.primary-btn {
                background: #00aeec;
                color: #fff;
                border-color: #00aeec;
            }

            .msc_panel button.primary-btn:hover {
                background: #0096cc;
            }

            .msc_panel button.warning-btn {
                background: #fb7299;
                color: #fff;
                border-color: #fb7299;
            }

            .msc_panel button.warning-btn:hover {
                background: #e45c80;
            }

            .msc_panel button:disabled {
                background: #eee;
                color: #999;
                cursor: not-allowed;
                border-color: #ddd;
            }

            .tutorial-btn {
                display: inline-block;
                padding: 8px 16px;
                background: #6c757d;
                color: #fff;
                text-decoration: none;
                border-radius: 4px;
                transition: all 0.2s;
            }

            .tutorial-btn:hover {
                background: #5a6268;
            }

            .log {
                margin-top: 16px;
                padding: 12px;
                background: #f6f7f8;
                border-radius: 4px;
                font-size: 14px;
                color: #666;
            }

            @keyframes buttonBlink {
                0% { opacity: 1; }
                50% { opacity: 0.7; }
                100% { opacity: 1; }
            }
            
            .msc_panel button.confirming {
                background-color: #ff6b6b !important;
                color: white !important;
            }
            
            .msc_panel button:disabled {
                animation: none !important;
                opacity: 0.5 !important;
            }

            .pin-settings {
                margin: 15px 0;
            }
            .setting-group {
                display: flex;
                flex-direction: column;
                gap: 10px;
            }
            .switch {
                display: flex;
                align-items: center;
                gap: 10px;
            }
            .switch input {
                display: none;
            }
            .slider {
                position: relative;
                width: 40px;
                height: 20px;
                background-color: #ccc;
                border-radius: 20px;
                cursor: pointer;
                transition: .4s;
            }
            .slider:before {
                position: absolute;
                content: "";
                height: 16px;
                width: 16px;
                left: 2px;
                bottom: 2px;
                background-color: white;
                border-radius: 50%;
                transition: .4s;
            }
            input:checked + .slider {
                background-color: #2196F3;
            }
            input:checked + .slider:before {
                transform: translateX(20px);
            }
            .pin-content-input {
                margin-top: 5px;
            }
            .pin-content-input input {
                width: 100%;
                padding: 5px;
                border: 1px solid #ddd;
                border-radius: 4px;
            }
            .tip {
                color: #999;
                font-size: 12px;
                margin-top: 5px;
                display: block;
            }

            /* 弹窗样式 */
            .log-popup {
                position: fixed;
                bottom: 20px;
                right: 20px;
                background: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 12px 20px;
                border-radius: 8px;
                z-index: 999999;
                font-size: 14px;
                max-width: 300px;
                animation: fadeInOut 0.3s ease-in-out;
                box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
            }

            @keyframes fadeInOut {
                0% {
                    opacity: 0;
                    transform: translateY(20px);
                }
                100% {
                    opacity: 1;
                    transform: translateY(0);
                }
            }

            .log-popup.hide {
                animation: fadeOut 0.3s ease-in-out forwards;
            }

            @keyframes fadeOut {
                0% {
                    opacity: 1;
                    transform: translateY(0);
                }
                100% {
                    opacity: 0;
                    transform: translateY(20px);
                }
            }

            .preserve-contents {
                display: flex;
                flex-direction: column;
                gap: 10px;
                margin-top: 10px;
            }
            
            .preserve-content-item {
                position: relative;
            }
            
            .input-wrapper {
                display: flex;
                gap: 8px;
                align-items: center;
            }
            
            .preserve-content {
                flex: 1;
                padding: 8px 12px;
                border: 1px solid #ddd;
                border-radius: 4px;
                width: 100%;
            }
            
            .add-preserve-content {
                align-self: flex-start;
                padding: 4px 12px;
                background: #00aeec;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-size: 16px;
                margin-top: 5px;
            }
            
            .add-preserve-content:hover {
                background: #0096cc;
            }
            
            .remove-content {
                padding: 4px 8px;
                background: #ff6b6b;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-size: 16px;
            }
            
            .remove-content:hover {
                background: #ff5252;
            }

            .most-liked-setting {
                margin-top: 15px;
            }
            
            .most-liked-count {
                margin-top: 10px;
                margin-left: 30px;
            }
            
            .most-liked-count input {
                width: 60px;
                padding: 5px;
                border: 1px solid #ddd;
                border-radius: 4px;
            }
            
            .most-liked-count input:disabled {
                background-color: #f5f5f5;
                cursor: not-allowed;
            }
        `;
        
        // 添加 @grant GM_addStyle 到脚本头部后,使用 GM_addStyle
        GM_addStyle(styles);
    }
 
    // 添加动态类型验证函数
    function isValidDynamicType(type) {
        const validTypes = [1, 2, 4, 8, 16, 64];
        return validTypes.includes(type);
    }
 
    // 检查是否存在置顶动态
    async function checkPinnedDynamic() {
        try {
            // 检查新版界面
            const newVersionPin = document.evaluate(
                '//*[@id="app"]/main/div[1]/div[2]/div/div/div/div[1]/div[1]/div/div[1]/div/div',
                document,
                null,
                XPathResult.FIRST_ORDERED_NODE_TYPE,
                null
            ).singleNodeValue;

            // 检查旧版界面
            const oldVersionPin = document.evaluate(
                '//*[@id="page-dynamic"]/div[1]/div/div[1]/div/div/div[1]/div/div',
                document,
                null,
                XPathResult.FIRST_ORDERED_NODE_TYPE,
                null
            ).singleNodeValue;

            return !!(newVersionPin || oldVersionPin);
        } catch (error) {
            console.error('检查置顶动态失败:', error);
            return false;
        }
    }
 
    // 添加新的保留内容输入框的函数
    function addPreserveContentInput() {
        const container = document.querySelector('.preserve-contents');
        const newItem = document.createElement('div');
        newItem.className = 'preserve-content-item';
        newItem.innerHTML = `
            <div class="input-wrapper">
                <input type="text" class="preserve-content" placeholder="输入要保留的动态文字内容">
                <button class="remove-content" title="删除此条件">×</button>
            </div>
            <small class="tip">输入动态中的部分内容即可,建议复制完整内容以提高匹配准确度</small>
        `;
        
        // 添加删除按钮的事件监听
        newItem.querySelector('.remove-content').addEventListener('click', () => {
            newItem.remove();
        });
        
        container.insertBefore(newItem, document.querySelector('.add-preserve-content'));
    }
 
    init();
})();