115网盘-全能视频清理(时长+VDI+持久化)

合并了视频时长清理与VDI清晰度清理功能。支持独立勾选、参数持久化保存、VDI对照表。

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         115网盘-全能视频清理(时长+VDI+持久化)
// @namespace    http://tampermonkey.net/
// @version      2.4
// @description  合并了视频时长清理与VDI清晰度清理功能。支持独立勾选、参数持久化保存、VDI对照表。
// @author       edhnt4551
// @match        https://115.com/*
// @icon         https://115.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    if (window.self !== window.top) return;

    // === 样式配置 ===
    GM_addStyle(`
        #ce-cleaner-btn {
            position: fixed; top: 120px; right: 20px; z-index: 2147483647;
            padding: 8px 15px; background: #2777F8; color: #fff;
            border-radius: 4px; cursor: pointer; font-size: 14px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: 0.3s;
            display: flex; align-items: center; gap: 5px; font-weight: 500;
        }
        #ce-cleaner-btn:hover { background: #1C66E6; transform: translateY(-1px); }
        #ce-cleaner-panel {
            position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
            width: 850px; max-height: 85vh; background: #fff; z-index: 2147483647;
            border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.4);
            display: none; flex-direction: column; overflow: hidden; font-size: 14px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            border: 1px solid #ddd;
        }
        .ce-header {
            padding: 15px 20px; border-bottom: 1px solid #eee;
            display: flex; justify-content: space-between; align-items: center;
            background: #f9f9f9;
        }
        .ce-header h3 { margin: 0; font-size: 16px; color: #333; }
        .ce-close { cursor: pointer; font-size: 24px; color: #999; line-height: 1; }
        .ce-close:hover { color: #333; }
        .ce-body { padding: 20px; overflow-y: auto; flex: 1; }

        /* 控制区样式 */
        .ce-controls {
            margin-bottom: 10px; background: #f0f7ff; padding: 15px; border-radius: 6px;
            border: 1px solid #e1ecf9; display: flex; flex-direction: column; gap: 10px;
        }
        .ce-control-row {
            display: flex; align-items: center; gap: 15px; padding-bottom: 10px; border-bottom: 1px dashed #dae8f9;
        }
        .ce-control-row:last-of-type { border-bottom: none; padding-bottom: 0; }

        .ce-group-label { font-weight:bold; color:#2777F8; font-size:13px; width: 80px; display:flex; align-items:center; gap:4px; }

        /* 选项样式 */
        .ce-opt-label { display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; color: #444; }
        .ce-opt-label:hover { color: #2777F8; }
        .ce-opt-label input[type="checkbox"] { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #2777F8; }

        .ce-input {
            padding: 4px; border: 1px solid #ddd; border-radius: 4px; width: 50px; text-align: center; font-size: 13px;
        }
        .ce-input:disabled { background: #f0f0f0; color: #bbb; border-color: #eee; cursor: not-allowed; }

        .ce-btn {
            padding: 6px 20px; border: none; border-radius: 4px; cursor: pointer; color: #fff; transition: 0.2s; font-weight:bold;
        }
        .ce-btn-primary { background: #2777F8; }
        .ce-btn-primary:hover { background: #1C66E6; }
        .ce-btn-danger { background: #E64C4C; }
        .ce-btn-danger:hover { background: #D63C3C; }
        .ce-btn-disabled { background: #ccc !important; cursor: not-allowed; }

        /* 提示说明区 */
        .ce-tips {
            margin-bottom: 15px; padding: 10px 15px; background: #fff8e1;
            border: 1px solid #ffe0b2; border-radius: 6px; color: #8d6e63; font-size: 12px;
            line-height: 1.6;
        }
        .ce-tips strong { color: #e65100; font-weight: 600; }
        .ce-vdi-table { margin-top: 5px; display: flex; flex-wrap: wrap; gap: 8px; }
        .ce-vdi-item { background: rgba(255,255,255,0.6); padding: 1px 6px; border-radius: 3px; border: 1px solid #ffe0b2; font-family: monospace; }

        /* 表格样式 */
        .ce-list-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
        .ce-list-table th, .ce-list-table td {
            text-align: left; padding: 10px; border-bottom: 1px solid #eee; font-size: 13px;
        }
        .ce-list-table th { background: #fff; position: sticky; top: 0; z-index: 1; border-bottom: 2px solid #eee; font-weight: 600; }

        .ce-footer { padding: 15px 20px; border-top: 1px solid #eee; text-align: right; background: #fff; }
        .ce-log { color: #666; font-size: 12px; margin-top: 10px; padding-left: 5px; }

        /* 标签样式 */
        .ce-tag { padding: 2px 6px; border-radius: 3px; font-size: 11px; margin-left: 5px; white-space: nowrap; }
        .ce-tag-short { background: #ffebeb; color: #e64c4c; border:1px solid #ffcdd2; }
        .ce-tag-long { background: #e6f3ff; color: #2777f8; border:1px solid #bbdefb; }
        .ce-tag-vdi { background: #fff3e0; color: #ef6c00; border:1px solid #ffe0b2; }
        .ce-vdi-badge { display:inline-block; font-size:10px; background:#eee; color:#555; padding:1px 4px; border-radius:3px; margin-right:4px; }
        .ce-divider { height: 16px; width: 1px; background: #ccc; margin: 0 5px; }
    `);

    // === 设置管理 (持久化) ===
    const settings = {
        save() {
            GM_setValue('cfg_min_on', document.getElementById('ce-chk-min-duration').checked);
            GM_setValue('cfg_min_val', document.getElementById('ce-duration-min').value);

            GM_setValue('cfg_max_on', document.getElementById('ce-chk-max-duration').checked);
            GM_setValue('cfg_max_val', document.getElementById('ce-duration-max').value);

            GM_setValue('cfg_vdi_on', document.getElementById('ce-chk-vdi').checked);
            GM_setValue('cfg_vdi_val', document.getElementById('ce-vdi-val').value);

            GM_setValue('cfg_vdi0_on', document.getElementById('ce-chk-vdi-zero').checked);
        },
        load() {
            // 设置默认值
            const setVal = (id, val) => { if(document.getElementById(id)) document.getElementById(id).value = val; };
            const setChk = (id, val) => {
                const el = document.getElementById(id);
                if(el) {
                    el.checked = val;
                    // 触发change事件以更新关联输入框的disabled状态,或者手动处理
                    const inputId = el.getAttribute('data-target');
                    if(inputId) document.getElementById(inputId).disabled = !val;
                }
            };

            setChk('ce-chk-min-duration', GM_getValue('cfg_min_on', false));
            setVal('ce-duration-min', GM_getValue('cfg_min_val', 8));

            setChk('ce-chk-max-duration', GM_getValue('cfg_max_on', false));
            setVal('ce-duration-max', GM_getValue('cfg_max_val', 60));

            setChk('ce-chk-vdi', GM_getValue('cfg_vdi_on', false));
            setVal('ce-vdi-val', GM_getValue('cfg_vdi_val', 3));

            setChk('ce-chk-vdi-zero', GM_getValue('cfg_vdi0_on', false));
        }
    };

    // === UI 构建 ===
    const ui = {
        btn: null,
        panel: null,
        init() {
            if (document.getElementById('ce-cleaner-btn')) return;

            // 悬浮球
            this.btn = document.createElement('div');
            this.btn.id = 'ce-cleaner-btn';
            this.btn.innerHTML = '🧹 视频清理助手';
            this.btn.title = "点击打开筛选面板";
            this.btn.onclick = (e) => {
                e.stopPropagation();
                this.togglePanel();
            };
            document.body.appendChild(this.btn);

            // 主面板
            this.panel = document.createElement('div');
            this.panel.id = 'ce-cleaner-panel';
            this.panel.innerHTML = `
                <div class="ce-header">
                    <h3>🎥 视频清理助手 (持久化版)</h3>
                    <span class="ce-close" onclick="document.getElementById('ce-cleaner-panel').style.display='none'">×</span>
                </div>
                <div class="ce-body">
                    <div class="ce-controls">
                        <!-- 时长控制行 -->
                        <div class="ce-control-row">
                            <span class="ce-group-label">⏱️ 时长</span>

                            <label class="ce-opt-label">
                                <input type="checkbox" id="ce-chk-min-duration" data-target="ce-duration-min">
                                <span>小于</span>
                                <input type="number" id="ce-duration-min" class="ce-input" placeholder="0" disabled>
                                <span>分钟</span>
                            </label>

                            <div class="ce-divider"></div>

                            <label class="ce-opt-label">
                                <input type="checkbox" id="ce-chk-max-duration" data-target="ce-duration-max">
                                <span>大于</span>
                                <input type="number" id="ce-duration-max" class="ce-input" placeholder="0" disabled>
                                <span>分钟</span>
                            </label>
                        </div>

                        <!-- VDI控制行 -->
                        <div class="ce-control-row">
                            <span class="ce-group-label">📺 清晰度</span>

                            <label class="ce-opt-label">
                                <input type="checkbox" id="ce-chk-vdi" data-target="ce-vdi-val">
                                <span>VDI 低于</span>
                                <input type="number" id="ce-vdi-val" class="ce-input" min="1" max="5" disabled>
                            </label>

                            <div class="ce-divider"></div>

                            <label class="ce-opt-label" title="VDI=0 通常表示转码中或无法识别">
                                <input type="checkbox" id="ce-chk-vdi-zero">
                                <span>删除未知清晰度(VDI=0)</span>
                            </label>
                        </div>

                         <div class="ce-control-row" style="justify-content: flex-end; border:none; padding-top:5px;">
                            <button id="ce-scan-btn" class="ce-btn ce-btn-primary">💾 保存设置并扫描</button>
                        </div>
                    </div>

                    <!-- 使用说明与VDI对照表 -->
                    <div class="ce-tips">
                        <div>💡 <strong>提示:</strong> 您的设置(勾选状态和数值)会在点击“保存设置并扫描”时自动保存,下次无需重新输入。</div>
                        <div style="margin-top:5px;">📊 <strong>VDI 清晰度参考表:</strong></div>
                        <div class="ce-vdi-table">
                            <span class="ce-vdi-item">1 = 低清</span>
                            <span class="ce-vdi-item">2 = 标清</span>
                            <span class="ce-vdi-item">3 = 高清</span>
                            <span class="ce-vdi-item">4 = 1080P</span>
                            <span class="ce-vdi-item">5 = 4K</span>
                            <span class="ce-vdi-item">100 = 原画</span>
                        </div>
                    </div>

                    <div id="ce-status" class="ce-log">请勾选上方条件并点击扫描...</div>

                    <div style="max-height: 400px; overflow-y: auto; border: 1px solid #eee; margin-top:5px; border-radius: 4px;">
                        <table class="ce-list-table">
                            <thead>
                                <tr>
                                    <th width="40"><input type="checkbox" id="ce-select-all" checked></th>
                                    <th>文件名</th>
                                    <th width="80">大小</th>
                                    <th width="100">VDI</th>
                                    <th width="140">删除原因</th>
                                </tr>
                            </thead>
                            <tbody id="ce-file-list"></tbody>
                        </table>
                    </div>
                </div>
                <div class="ce-footer">
                    <span id="ce-summary" style="margin-right: 20px; font-weight: bold; color: #E64C4C;"></span>
                    <button id="ce-delete-btn" class="ce-btn ce-btn-danger ce-btn-disabled" disabled>删除选中文件 (回收站)</button>
                </div>
            `;
            document.body.appendChild(this.panel);

            // 交互逻辑:复选框控制输入框禁用状态
            this.bindCheckbox('ce-chk-min-duration', 'ce-duration-min');
            this.bindCheckbox('ce-chk-max-duration', 'ce-duration-max');
            this.bindCheckbox('ce-chk-vdi', 'ce-vdi-val');

            // 恢复保存的设置
            settings.load();

            // 事件绑定
            document.getElementById('ce-scan-btn').onclick = () => core.scan();
            document.getElementById('ce-delete-btn').onclick = () => core.delete();
            document.getElementById('ce-select-all').onchange = (e) => core.toggleAll(e.target.checked);
        },
        bindCheckbox(chkId, inputId) {
            const chk = document.getElementById(chkId);
            const input = document.getElementById(inputId);
            chk.addEventListener('change', () => {
                input.disabled = !chk.checked;
                if(chk.checked) input.focus();
            });
        },
        togglePanel() {
            this.panel.style.display = this.panel.style.display === 'flex' ? 'none' : 'flex';
        },
        log(msg) {
            const statusEl = document.getElementById('ce-status');
            if(statusEl) statusEl.innerText = msg;
        },
        renderList(files) {
            const tbody = document.getElementById('ce-file-list');
            tbody.innerHTML = '';
            if (files.length === 0) {
                tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:#999; padding: 20px;">没有发现符合条件的文件</td></tr>';
                this.updateSummary(0);
                return;
            }

            files.forEach(f => {
                const tr = document.createElement('tr');

                // 构建原因标签
                let reasonHtml = '';
                if (f._reasons.includes('short')) reasonHtml += `<span class="ce-tag ce-tag-short">时长<${f._limit_min}m</span>`;
                if (f._reasons.includes('long')) reasonHtml += `<span class="ce-tag ce-tag-long">时长>${f._limit_max}m</span>`;
                if (f._reasons.includes('low_vdi')) reasonHtml += `<span class="ce-tag ce-tag-vdi">VDI<${f._limit_vdi}</span>`;
                if (f._reasons.includes('zero_vdi')) reasonHtml += `<span class="ce-tag ce-tag-vdi">画质未知</span>`;

                // 时长显示
                const timeStr = core.formatTime(f.play_long);

                tr.innerHTML = `
                    <td><input type="checkbox" class="ce-file-chk" value="${f.fid}" checked></td>
                    <td title="${f.n}">
                        <div style="font-weight:500; word-break:break-all;">${f.n}</div>
                    </td>
                    <td>${core.formatSize(f.s)}</td>
                    <td>
                        <span class="ce-vdi-badge">VDI: ${f.vdi || 0}</span>
                        <div style="font-size:11px; color:#888;">${timeStr}</div>
                    </td>
                    <td>${reasonHtml}</td>
                `;
                tbody.appendChild(tr);
            });
            this.updateSummary(files.length);
        },
        updateSummary(count) {
            const btn = document.getElementById('ce-delete-btn');
            document.getElementById('ce-summary').innerText = `共选中 ${count} 个文件`;
            if (count > 0) {
                btn.removeAttribute('disabled');
                btn.classList.remove('ce-btn-disabled');
            } else {
                btn.setAttribute('disabled', 'true');
                btn.classList.add('ce-btn-disabled');
            }
        }
    };

    // === 核心逻辑 ===
    const core = {
        cid: 0,
        getCid() {
            try {
                if (window.TOP && window.TOP.API && window.TOP.API.aid) return window.TOP.API.cid;
            } catch(e) {}
            const match = location.href.match(/[?&]cid=(\d+)/);
            if (match) return match[1];
            return 0;
        },
        formatTime(seconds) {
            if (!seconds) return '00:00';
            const h = Math.floor(seconds / 3600);
            const m = Math.floor((seconds % 3600) / 60);
            const s = Math.floor(seconds % 60);
            if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
            return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
        },
        formatSize(bytes) {
            if (bytes === 0) return '0 B';
            const k = 1024;
            const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
        },
        async scan() {
            // 保存当前设置
            settings.save();

            this.cid = this.getCid();
            if (!this.cid || this.cid == '0') {
                alert('⚠️ 未获取到文件夹ID。\n\n请进入具体的文件夹后再点击扫描。');
                return;
            }

            // === 获取启用状态 ===
            const useMin = document.getElementById('ce-chk-min-duration').checked;
            const useMax = document.getElementById('ce-chk-max-duration').checked;
            const useVdi = document.getElementById('ce-chk-vdi').checked;
            const useVdiZero = document.getElementById('ce-chk-vdi-zero').checked;

            if (!useMin && !useMax && !useVdi && !useVdiZero) {
                alert('⚠️ 请至少勾选一个筛选条件!');
                return;
            }

            // === 获取数值 ===
            const minVal = parseFloat(document.getElementById('ce-duration-min').value) || 0;
            const maxVal = parseFloat(document.getElementById('ce-duration-max').value) || 0;
            const minSec = minVal * 60;
            const maxSec = maxVal * 60;
            const vdiThreshold = parseInt(document.getElementById('ce-vdi-val').value) || 0;

            ui.log('正在获取文件列表...');
            ui.renderList([]);

            try {
                const files = await this.fetchAllFiles(this.cid);
                const targets = files.filter(f => {
                    if (!f.fid) return false;

                    let reasons = [];
                    const duration = parseFloat(f.play_long || 0);
                    const vdi = isNaN(parseInt(f.vdi)) ? 0 : parseInt(f.vdi);

                    // 1. 时长判断
                    if (duration > 0) {
                        if (useMin && minSec > 0 && duration < minSec) {
                            reasons.push('short');
                            f._limit_min = minVal;
                        }
                        if (useMax && maxSec > 0 && duration > maxSec) {
                            reasons.push('long');
                            f._limit_max = maxVal;
                        }
                    }

                    // 2. VDI(清晰度)判断
                    if (vdi === 0) {
                        if (useVdiZero) reasons.push('zero_vdi');
                    } else {
                        if (useVdi && vdiThreshold > 0 && vdi < vdiThreshold) {
                            reasons.push('low_vdi');
                            f._limit_vdi = vdiThreshold;
                        }
                    }

                    if (reasons.length > 0) {
                        f._reasons = reasons;
                        return true;
                    }
                    return false;
                });

                ui.renderList(targets);
                ui.log(`✅ 扫描完成。共检索 ${files.length} 个视频,命中 ${targets.length} 个。`);

            } catch (e) {
                console.error(e);
                ui.log('❌ 扫描出错: ' + e.message);
            }
        },

        async fetchAllFiles(cid) {
            let allData = [];
            let offset = 0;
            const limit = 1150;
            while (true) {
                ui.log(`正在加载第 ${Math.floor(offset/limit) + 1} 页数据...`);
                const url = `https://webapi.115.com/files?aid=1&cid=${cid}&o=user_ptime&asc=0&offset=${offset}&show_dir=0&limit=${limit}&code=&scid=&snap=0&natsort=1&type=4&format=json`;
                const data = await this.request(url);
                if (!data.state) throw new Error(data.error || 'API请求失败,请检查登录状态');
                if (data.data && data.data.length > 0) allData = allData.concat(data.data);

                if (allData.length >= data.count || data.data.length < limit) break;

                offset += limit;
                await new Promise(r => setTimeout(r, 200));
            }
            return allData;
        },

        async delete() {
            const checkboxes = document.querySelectorAll('.ce-file-chk:checked');
            if (checkboxes.length === 0) return;
            if (!confirm(`⚠️ 高能预警:\n\n即将删除选中的 ${checkboxes.length} 个文件。\n\n文件将移入【回收站】,确定继续吗?`)) return;

            const ids = Array.from(checkboxes).map(cb => cb.value);
            const btn = document.getElementById('ce-delete-btn');
            const originalText = btn.innerText;
            btn.innerText = '正在执行删除...';
            btn.setAttribute('disabled', 'true');

            try {
                const batchSize = 500;
                for (let i = 0; i < ids.length; i += batchSize) {
                    const chunk = ids.slice(i, i + batchSize);
                    await this.deleteBatch(chunk);
                    ui.log(`🗑️ 已删除 ${Math.min(i + batchSize, ids.length)} / ${ids.length} ...`);
                }
                ui.log('✅ 删除完成!请手动刷新网页查看结果。');
                alert('清理完成!文件已移入回收站。');
                ui.togglePanel();
                setTimeout(()=> location.reload(), 1000);
            } catch (e) {
                alert('删除出错:' + e.message);
                btn.innerText = originalText;
                btn.removeAttribute('disabled');
            }
        },

        async deleteBatch(fids) {
            const fd = new FormData();
            fd.append('pid', this.cid);
            fd.append('ignore_warn', '1');
            fids.forEach((fid, index) => { fd.append(`fid[${index}]`, fid); });
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "POST",
                    url: "https://webapi.115.com/rb/delete",
                    data: fd,
                    headers: {
                        "Origin": "https://115.com",
                        "Referer": "https://115.com/",
                    },
                    onload: (res) => {
                        try {
                            const json = JSON.parse(res.responseText);
                            if (json.state) resolve(json); else reject(new Error(json.error));
                        } catch(e) {
                            reject(new Error("返回数据解析失败"));
                        }
                    },
                    onerror: (e) => reject(e)
                });
            });
        },

        request(url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    headers: { "User-Agent": navigator.userAgent, "Accept": "application/json" },
                    onload: (response) => {
                        if (response.status === 200) {
                            try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(e); }
                        } else { reject(new Error("HTTP " + response.status)); }
                    },
                    onerror: (err) => reject(err)
                });
            });
        },

        toggleAll(checked) {
            document.querySelectorAll('.ce-file-chk').forEach(cb => cb.checked = checked);
        }
    };

    setTimeout(() => { ui.init(); }, 1000);
})();