班固米长篇点格子增强

为 Bangumi 长篇剧集提供「看到」操作拦截,和范围标记功能

// ==UserScript==
// @name        班固米长篇点格子增强
// @version      0.1
// @description  为 Bangumi 长篇剧集提供「看到」操作拦截,和范围标记功能
// @match        https://bgm.tv/subject/*
// @match        https://bangumi.tv/subject/*
// @match        https://chii.in/subject/*
// @grant        none
// @license      MIT
// @namespace https://greasyfork.org/users/919437
// ==/UserScript==

(function () {
    'use-strict';

    // --- 配置 ---
    const LONG_SERIES_THRESHOLD = 25;
    const API_CALL_DELAY = 250;
    const MAX_WAIT_TIME = 10000;

    // --- 状态映射表 ---
    const STATUS_MAP = {
        'w': { text: '看过', apiKey: 'watched', cssClass: 'epBtnWatched' },
        'q': { text: '想看', apiKey: 'queue',   cssClass: 'epBtnQueue' }, // 经确认,class为 epBtnQueue
        'd': { text: '抛弃', apiKey: 'drop',    cssClass: 'epBtnDropped' } // 猜测 class 为 epBtnDropped
    };

    // --- 脚本状态变量 ---
    let selectionState = {
        isActive: false,
        step: 'start', // 'start' or 'end'
    };

    /**
     * 等待目标元素出现在页面上
     * @param {string} selector - CSS选择器
     * @returns {Promise<Element>}
     */
    function waitForElement(selector) {
        return new Promise((resolve, reject) => {
            const interval = setInterval(() => {
                const el = document.querySelector(selector);
                if (el) {
                    clearInterval(interval);
                    resolve(el);
                }
            }, 100);
            setTimeout(() => {
                clearInterval(interval);
                reject(new Error(`等待元素 "${selector}" 超时.`));
            }, MAX_WAIT_TIME);
        });
    }

    /**
     * 显示自定义模态对话框
     * @param {string} title - 对话框标题
     * @param {string} contentHtml - 对话框内容 (HTML)
     * @param {boolean} showCancel - 是否显示取消按钮
     * @returns {Promise<void>} 当用户点击确认时 resolve,点击取消或关闭时 reject
     */
    function showModal(title, contentHtml, showCancel = true) {
        return new Promise((resolve, reject) => {
            // 移除已存在的模态框
            const existingModal = document.getElementById('bgm-manager-modal');
            if (existingModal) existingModal.remove();

            const modalOverlay = document.createElement('div');
            modalOverlay.id = 'bgm-manager-modal';
            modalOverlay.style.cssText = `
                position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                background-color: rgba(0, 0, 0, 0.6); z-index: 9999;
                display: flex; align-items: center; justify-content: center;
            `;

            modalOverlay.innerHTML = `
                <div style="background: #fff; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); width: 450px; max-width: 90%;">
                    <h3 style="margin: 0; padding: 15px 20px; border-bottom: 1px solid #e0e0e0; font-size: 16px; color: #333;">${title}</h3>
                    <div style="padding: 20px; line-height: 1.6; color: #555; max-height: 40vh; overflow-y: auto;">${contentHtml}</div>
                    <div style="padding: 10px 20px; border-top: 1px solid #e0e0e0; text-align: right;">
                        ${showCancel ? '<button id="modal-cancel-btn" class="chiiBtn" style="margin-right: 10px;">取消</button>' : ''}
                        <button id="modal-confirm-btn" class="inputBtn">确认</button>
                    </div>
                </div>
            `;

            document.body.appendChild(modalOverlay);

            const confirmBtn = document.getElementById('modal-confirm-btn');
            const cancelBtn = document.getElementById('modal-cancel-btn');

            const closeModal = () => modalOverlay.remove();

            confirmBtn.onclick = () => {
                closeModal();
                resolve();
            };
            if (cancelBtn) {
                cancelBtn.onclick = () => {
                    closeModal();
                    reject(new Error('User cancelled'));
                };
            }
            modalOverlay.onclick = (e) => {
                if (e.target === modalOverlay) {
                     closeModal();
                     reject(new Error('User cancelled'));
                }
            };
        });
    }

    /**
     * 脚本主函数
     */
    async function main() {
        const subjectId = window.location.pathname.match(/\/subject\/(\d+)/)?.[1];
        if (!subjectId) return;

        try {
            const response = await fetch(`https://api.bgm.tv/v0/subjects/${subjectId}`);
            const data = await response.json();

            if (data && data.eps != null && (!data.eps || data.eps > LONG_SERIES_THRESHOLD)) {
                const prgManager = await waitForElement('.subject_prg');
                initializeManager(prgManager);
            }
        } catch (error) {
            console.error('BGM 范围标记脚本出错:', error);
        }
    }

    /**
     * 初始化管理器
     * @param {HTMLElement} prgManager - 进度管理区块元素
     */
    function initializeManager(prgManager) {
        const panel = createManagerPanel();
        prgManager.appendChild(panel);

        const toggleBtn = document.createElement('a');
        toggleBtn.href = 'javascript:void(0);';
        toggleBtn.className = 'l';
        toggleBtn.textContent = '长篇管理';
        toggleBtn.style.marginLeft = '10px';
        toggleBtn.onclick = () => {
            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        };

        prgManager.querySelector('a.l').insertAdjacentElement('afterend', toggleBtn);

        // Hook "看到(WatchedTill)" 操作
        if (window.chiiLib?.home?.epStatusClick) {
            const origEpStatusClick = window.chiiLib.home.epStatusClick;
            window.chiiLib.home.epStatusClick = async function (targetElement) {
                const statusType = $(targetElement).attr('id').split('_')[0];
                if (statusType === 'WatchedTill') {
                    try {
                        const epNum = $(targetElement).closest('.prg_popup').parent().find('.epBtnAir, .epBtnQueue, .epBtnDropped').first().text();
                        await showModal('操作确认', `确定要将第 1 集到第 ${epNum} 集全部标记为“看过”吗?`);
                        origEpStatusClick.apply(this, arguments);
                    } catch (e) {
                        // 用户取消操作
                    }
                } else {
                     origEpStatusClick.apply(this, arguments);
                }
            };
        }
    }

    /**
     * 创建主管理面板 (包含两种模式)
     */
    function createManagerPanel() {
        const panel = document.createElement('div');
        panel.id = 'range-manager-panel';
        panel.style.cssText = 'display: none; border: 1px solid #E0E0E0; border-radius: 5px; margin-top: 10px; background-color: #F9F9F9;';

        // Tab 切换
        panel.innerHTML = `
            <div id="manager-tabs" style="display: flex; border-bottom: 1px solid #e0e0e0;">
                <button class="tab-btn active" data-tab="interactive">范围选择</button>
                <button class="tab-btn" data-tab="batch">文件式批量管理</button>
            </div>
            <div id="manager-content" style="padding: 10px;"></div>
        `;

        const contentDiv = panel.querySelector('#manager-content');
        contentDiv.innerHTML = createInteractivePanelHtml(); // 默认显示交互模式

        panel.querySelectorAll('.tab-btn').forEach(btn => {
            btn.onclick = (e) => {
                panel.querySelector('.tab-btn.active').classList.remove('active');
                e.target.classList.add('active');
                if (e.target.dataset.tab === 'interactive') {
                    contentDiv.innerHTML = createInteractivePanelHtml();
                    bindInteractivePanelEvents();
                } else {
                    contentDiv.innerHTML = createBatchPanelHtml();
                    bindBatchPanelEvents();
                }
            };
        });

        // Tab 样式
        const style = document.createElement('style');
        style.textContent = `
            .tab-btn { background: none; border: none; padding: 10px 15px; cursor: pointer; color: #555; font-size: 13px; }
            .tab-btn.active { background-color: #fff; border-bottom: 2px solid #f09199; font-weight: bold; }
            #batch-textarea { width: 95%; min-height: 100px; padding: 5px; border: 1px solid #ccc; border-radius: 3px; font-family: monospace; }
        `;
        document.head.appendChild(style);

        // 绑定默认面板的事件
        setTimeout(() => bindInteractivePanelEvents(), 0);

        return panel;
    }

    // --- 交互选择模式 ---
    function createInteractivePanelHtml() {
        return `
            <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
                <label>范围:</label>
                <input type="number" id="range-start" class="inputtext" style="width: 60px;" min="1" placeholder="开始">
                <span>-</span>
                <input type="number" id="range-end" class="inputtext" style="width: 60px;" min="1" placeholder="结束">
                <button id="interactive-select-btn" class="chiiBtn">交互选择模式</button>
            </div>
            <button id="mark-watched-interactive-btn" class="inputBtn" style="width: 100%;">标记为看过</button>
            <div id="interactive-progress" style="margin-top: 5px; color: #666; font-size: 12px;"></div>
        `;
    }

    function bindInteractivePanelEvents() {
        document.getElementById('interactive-select-btn').onclick = toggleSelectionMode;
        document.getElementById('mark-watched-interactive-btn').onclick = () => {
            const start = document.getElementById('range-start').value;
            const end = document.getElementById('range-end').value;
            executeMarking([{ start, end, status: 'w' }], 'interactive-progress');
        };
    }

    function toggleSelectionMode() {
        const btn = document.getElementById('interactive-select-btn');
        const episodeList = document.querySelector('ul.prg_list');
        selectionState.isActive = !selectionState.isActive;

        if (selectionState.isActive) {
            selectionState.step = 'start';
            btn.textContent = '请选择开始集数';
            btn.classList.add('inputBtn'); //
            episodeList.style.cursor = 'pointer';
            episodeList.addEventListener('click', handleEpisodeSelection);
        } else {
            resetSelectionMode();
        }
    }

    function resetSelectionMode() {
        const btn = document.getElementById('interactive-select-btn');
        const episodeList = document.querySelector('ul.prg_list');
        selectionState.isActive = false;
        if(btn) btn.textContent = '范围选择';
        if(btn) btn.classList.remove('inputBtn');
        if(episodeList) {
            episodeList.style.cursor = '';
            episodeList.removeEventListener('click', handleEpisodeSelection);
        }
    }

    function handleEpisodeSelection(e) {
        e.preventDefault();
        const epNum = parseInt(e.target.textContent, 10);
        if (isNaN(epNum)) return;

        const startInput = document.getElementById('range-start');
        const endInput = document.getElementById('range-end');
        const btn = document.getElementById('interactive-select-btn');

        if (selectionState.step === 'start') {
            startInput.value = epNum;
            endInput.value = ''; // 清空结束,以便重新选择
            selectionState.step = 'end';
            btn.textContent = '请选择结束集数';
        } else {
            // 自动判断大小并填充
            const startVal = parseInt(startInput.value, 10);
            if (epNum < startVal) {
                endInput.value = startVal;
                startInput.value = epNum;
            } else {
                endInput.value = epNum;
            }
            resetSelectionMode();
        }
    }

    // --- 批量输入模式 ---
    function createBatchPanelHtml() {
        return `
            <p style="font-size: 12px; color: #666; margin-top: 0;">每行一条指令: <b>开始集数 结束集数 状态码</b><br>状态码: <b>w</b>=看过, <b>q</b>=想看, <b>d</b>=抛弃</p>
            <textarea id="batch-textarea" placeholder="例如:\n1 12 w\n25 25 d\n130 120 q (范围会自动修正)"></textarea>
            <button id="batch-execute-btn" class="inputBtn" style="margin-top: 10px; width: 100%;">预览并执行</button>
            <div id="batch-progress" style="margin-top: 5px; color: #666; font-size: 12px;"></div>
        `;
    }

    function bindBatchPanelEvents() {
        document.getElementById('batch-execute-btn').onclick = handleBatchInput;
    }

    async function handleBatchInput() {
        const text = document.getElementById('batch-textarea').value;
        const lines = text.split('\n').filter(line => line.trim() !== '');
        const progressDiv = document.getElementById('batch-progress');
        let tasks = [];
        let errors = [];

        lines.forEach((line, index) => {
            const parts = line.trim().split(/\s+/);
            if (parts.length !== 3) {
                errors.push(`第 ${index + 1} 行格式错误。`);
                return;
            }
            let [start, end, status] = parts;
            start = parseInt(start, 10);
            end = parseInt(end, 10);
            status = status.toLowerCase();

            if (isNaN(start) || isNaN(end)) {
                errors.push(`第 ${index + 1} 行的集数无效。`);
                return;
            }
            if (!STATUS_MAP[status]) {
                errors.push(`第 ${index + 1} 行的状态码 "${status}" 无效。`);
                return;
            }

            // 智能修正范围
            if (start > end) [start, end] = [end, start];

            tasks.push({ start, end, status });
        });

        if (errors.length > 0) {
            progressDiv.textContent = errors.join(' ');
            return;
        }
        if(tasks.length === 0) {
            progressDiv.textContent = '没有可执行的操作。'; return;
        }

        const previewHtml = tasks.map(t => `<li>将 ${t.start}-${t.end} 集标记为 <b>${STATUS_MAP[t.status].text}</b></li>`).join('');

        try {
            await showModal('操作预览', `<p>将执行以下 ${tasks.length} 个操作,请确认:</p><ul style="margin-top: 10px; padding-left: 20px;">${previewHtml}</ul>`);
            executeMarking(tasks, 'batch-progress');
        } catch(e) {
            progressDiv.textContent = '操作已取消。';
        }
    }

    // --- 通用执行与工具函数 ---
    async function executeMarking(tasks, progressDivId) {
        const progressDiv = document.getElementById(progressDivId);
        const allButtons = document.querySelectorAll('#interactive-select-btn, #mark-watched-interactive-btn, #batch-execute-btn');

        allButtons.forEach(btn => btn.disabled = true);
        progressDiv.textContent = '正在准备...';

        const epMap = getEpisodeMap();
        const token = getSecurityToken();
        if (!token) {
            progressDiv.textContent = '错误: 无法获取安全令牌。';
            allButtons.forEach(btn => btn.disabled = false);
            return;
        }

        let totalMarked = 0;
        for (const task of tasks) {
            for (let i = task.start; i <= task.end; i++) {
                const epInfo = epMap[i];
                if (epInfo) {
                    progressDiv.textContent = `标记中: ${i}集 -> ${STATUS_MAP[task.status].text}...`;
                    await updateEpStatus(epInfo.id, task.status, token);
                    updateButtonLook(epInfo.button, task.status);
                    totalMarked++;
                }
            }
        }

        progressDiv.textContent = `完成! 共标记了 ${totalMarked} 个剧集。`;
        allButtons.forEach(btn => btn.disabled = false);
    }

    function updateEpStatus(epId, statusKey, token) {
        const apiKey = STATUS_MAP[statusKey].apiKey;
        const url = `/subject/ep/${epId}/status/${apiKey}?gh=${token}`;
        return new Promise(resolve => {
            fetch(url)
                .catch(err => console.error(`更新剧集 ${epId} 失败:`, err))
                .finally(() => setTimeout(resolve, API_CALL_DELAY));
        });
    }

    function updateButtonLook(button, statusKey) {
        if (!button) return;
        Object.values(STATUS_MAP).forEach(s => button.classList.remove(s.cssClass));
        button.classList.remove('epBtnAir', 'epBtnNA', 'epBtnUnknown');
        button.classList.add(STATUS_MAP[statusKey].cssClass);
    }

    function getEpisodeMap() {
        const map = {};
        document.querySelectorAll('ul.prg_list li a').forEach(btn => {
            const epNum = parseInt(btn.textContent, 10);
            const epIdMatch = btn.id.match(/prg_(\d+)/);
            if (!isNaN(epNum) && epIdMatch) map[epNum] = { id: epIdMatch[1], button: btn };
        });
        return map;
    }

    function getSecurityToken() {
        const sampleLink = document.querySelector('div.prg_popup a[id^="Watched_"]');
        return sampleLink ? sampleLink.href.match(/gh=([a-f0-9]+)/)?.[1] : null;
    }

    main();

})();