Bangumi NSFW 大过滤器

自动检测并模糊化 Bangumi 帖子中的 NSFW 图片。

// ==UserScript==
// @name         Bangumi NSFW 大过滤器
// @version      1.0.1
// @author       wataame
// @match        https://bgm.tv/group/topic/*
// @match        https://bangumi.tv/group/topic/*
// @match        https://chii.in/group/topic/*
// @match        https://bgm.tv/subject/topic/*
// @match        https://bangumi.tv/subject/topic/*
// @match        https://chii.in/subject/topic/*
// @match        https://bgm.tv/blog/*
// @match        https://bangumi.tv/blog/*
// @match        https://chii.in/blog/*
// @match        https://bgm.tv/settings/privacy
// @match        https://bangumi.tv/settings/privacy
// @match        https://chii.in/settings/privacy
// @grant        none
// @license      MIT
// @run-at       document-idle
// @namespace https://gf.qytechs.cn/users/1389779
// @description 自动检测并模糊化 Bangumi 帖子中的 NSFW 图片。
// ==/UserScript==

(function() {
    'use strict';

    const BACKEND_URL = 'https://nsfw.ry.mk';
    const PROCESS_ENDPOINT = `${BACKEND_URL}/process-image`;
    const REPORT_ENDPOINT = `${BACKEND_URL}/report-image`;
    const BLUR_AMOUNT = '35px';
    const HOVER_BLUR_AMOUNT = '30px';
    const STORAGE_KEY_NSFW_THRESHOLD = 'bgm_nsfw_filter_threshold';
    const STORAGE_KEY_SCRIPT_ENABLED = 'bgm_nsfw_filter_enabled';

    let NSFW_THRESHOLDS = {};

    function loadNsfwThresholds() {
        const storedValue = localStorage.getItem(STORAGE_KEY_NSFW_THRESHOLD) || '10';
        const threshold = parseInt(storedValue, 10) / 100;
        NSFW_THRESHOLDS = { 'Porn': threshold, 'Hentai': threshold, 'Sexy': threshold };
        console.log(`Bangumi NSFW Filter: Threshold loaded and set to ${threshold}`);
    }

    function initSettingsUI() {
        const anchor = document.querySelector('#columnA .settings:last-of-type');
        if (!anchor) { console.warn('Bangumi NSFW Filter: Settings anchor not found.'); return; }
        const settingsHTML = `
            <form id="nsfwFilterSettingsForm" style="margin-top: 1.5em;">
                <table align="center" width="98%" cellspacing="0" cellpadding="5" class="settings">
                    <tbody>
                        <tr><td valign="top" colspan="2"><h2 class="subtitle">Bangumi NSFW 大过滤器 (脚本设置)</h2></td></tr>
                        <tr>
                            <td valign="top" width="25%">脚本开关</td>
                            <td valign="top">
                                <input type="checkbox" id="nsfwScriptEnabledCheckbox" style="vertical-align: middle;">
                                <label for="nsfwScriptEnabledCheckbox" style="vertical-align: middle;"> 启用 NSFW 过滤器</label>
                            </td>
                        </tr>
                        <tr>
                            <td valign="top" width="25%">NSFW 识别阈值</td>
                            <td valign="top">
                                <input type="range" id="nsfwThresholdSlider" min="5" max="95" step="1" style="vertical-align: middle;">
                                <span id="nsfwThresholdValue" style="margin-left: 10px; font-weight: bold; width: 40px; display: inline-block;"></span>
                                <p class="tip_j">阈值越低,审查越严格。</p>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </form>
        `;
        anchor.insertAdjacentHTML('afterend', settingsHTML);

        const enabledCheckbox = document.getElementById('nsfwScriptEnabledCheckbox');
        const isEnabled = localStorage.getItem(STORAGE_KEY_SCRIPT_ENABLED) !== 'false';
        enabledCheckbox.checked = isEnabled;
        enabledCheckbox.addEventListener('change', () => {
            localStorage.setItem(STORAGE_KEY_SCRIPT_ENABLED, enabledCheckbox.checked);
            console.log(`Bangumi NSFW Filter: Script enabled set to ${enabledCheckbox.checked}`);
        });

        const slider = document.getElementById('nsfwThresholdSlider');
        const valueDisplay = document.getElementById('nsfwThresholdValue');
        const updateDisplay = (value) => { valueDisplay.textContent = `${value}%`; };
        const saveThreshold = (value) => {
            localStorage.setItem(STORAGE_KEY_NSFW_THRESHOLD, value);
            console.log(`Bangumi NSFW Filter: Threshold saved to ${value}%`);
            loadNsfwThresholds();
        };
        const currentStoredValue = localStorage.getItem(STORAGE_KEY_NSFW_THRESHOLD) || '10';
        slider.value = currentStoredValue;
        updateDisplay(currentStoredValue);
        slider.addEventListener('input', () => updateDisplay(slider.value));
        slider.addEventListener('change', () => saveThreshold(slider.value));
        console.log('Bangumi NSFW Filter: Settings UI injected.');
    }

    const allCSS = `
        .nsfw-image-wrapper { position: relative; display: inline-block; min-width: 32px; min-height: 32px; vertical-align: middle; line-height: 15px; }
        .nsfw-image-wrapper:hover .nsfw-report-bar { opacity: 1; visibility: visible; }
        .nsfw-image-wrapper img.nsfw-image-element { display: block; height: auto; transition: filter 0.3s ease-in-out; }
        .nsfw-image-wrapper img.nsfw-image-element.nsfw-blurred { filter: blur(${BLUR_AMOUNT}); }
        .nsfw-image-wrapper img.nsfw-image-element.nsfw-blurred.unlocked:hover { filter: blur(${HOVER_BLUR_AMOUNT}); }
        .nsfw-image-wrapper.clickable { cursor: pointer; }
        .nsfw-report-bar { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); border-radius: 4px; padding: 2px 5px; opacity: 0; visibility: hidden; transition: opacity 0.2s, visibility 0.2s; z-index: 11; font-family: sans-serif; }
        .nsfw-report-bar .report-msg { font-size: 10px; color: white; user-select: none; }
        .nsfw-report-bar .report-msg.clickable { cursor: pointer; }
        .nsfw-report-bar .report-msg.clickable:hover { text-decoration: underline; color: #a5d6a7; }
        .nsfw-indicator {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 6px 12px;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            font-family: sans-serif;
            z-index: 10;
            pointer-events: none;
            user-select: none;
        }
        .nsfw-loader {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 32px;
            height: 32px;
            margin-top: -16px;
            margin-left: -16px;
            border: 4px solid rgba(255, 255, 255, 0.3);
            border-top-color: #ffffff;
            border-radius: 50%;
            animation: nsfw-spin 1s linear infinite;
            z-index: 12;
        }
        @keyframes nsfw-spin {
            to {
                transform: rotate(360deg);
            }
        }
        #nsfw-report-dialog-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center; }
        #nsfw-report-dialog { background: #fdfdfd; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); width: 450px; max-width: 90%; font-family: sans-serif; }
        .nsfw-report-dialog-header { padding: 12px 15px; border-bottom: 1px solid #e0e0e0; font-size: 16px; font-weight: bold; color: #333; }
        .nsfw-report-dialog-content { padding: 20px 15px; line-height: 1.6; font-size: 14px; }
        .nsfw-report-dialog-footer { padding: 10px 15px; text-align: right; border-top: 1px solid #e0e0e0; background: #f7f7f7; }
        .nsfw-report-dialog-footer button { margin-left: 10px; padding: 5px 15px; border-radius: 4px; border: 1px solid #ccc; cursor: pointer; }
    `;
    function addGlobalStyle(css) {
        const style = document.createElement('style'); style.type = 'text/css';
        style.appendChild(document.createTextNode(css)); document.head.appendChild(style);
    }

    async function fetchWithTimeout(resource, options = {}, timeout = 10000) {
        const controller = new AbortController();
        const id = setTimeout(() => controller.abort(), timeout);
        try {
            const response = await fetch(resource, { ...options, signal: controller.signal });
            clearTimeout(id); return response;
        } catch (error) { clearTimeout(id); throw error; }
    }

    async function reportImage(imageUrl, reportType, reportBar) {
        try {
            reportBar.innerHTML = `<span class="report-msg">正在报告...</span>`;
            const payload = JSON.stringify({ image_url: imageUrl, report_type: reportType });
            const response = await fetchWithTimeout(REPORT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload });
            if (!response.ok) { throw new Error(`Report request failed (${response.status})`); }
            const result = await response.json();
            reportBar.innerHTML = `<span class="report-msg">${result.message || '感谢报告!'}</span>`;
        } catch (error) {
            console.error('Bangumi NSFW Filter: Reporting error', error);
            reportBar.innerHTML = `<span class="report-msg" style="color: #ef9a9a;">报告失败</span>`;
        }
    }

    function isImageEligible(img) {
        if (img.dataset.nsfwProcessed === 'true' || img.closest('.nsfw-image-wrapper')) return false;
        if (img.closest('a.avatar, .avatar') || img.classList.contains('avatarNeue')) return false;
        if (img.src && (img.src.includes('/img/smiles/') || img.getAttribute('smileid') || img.classList.contains('emoji'))) return false;
        if (img.src && img.src.includes('/img/code/')) return false;
        if (!img.src || img.src.startsWith('blob:') || img.src.startsWith('data:')) return false;
        if (!img.closest('.topic_content, .blog_entry, .message, .cmt_sub_content')) return false;
        if (img.complete && img.naturalWidth > 0 && (img.naturalWidth <= 16 && img.naturalHeight <= 16)) return false;
        return true;
    }

    function cleanupAndFinalize(imgElement, wrapper) {
        if (imgElement.parentNode === wrapper) {
            wrapper.parentNode.insertBefore(imgElement, wrapper);
            wrapper.remove();
        }
    }

    function showReportDialog(imageUrl, currentStatusText, reportBarElement) {
        const existingDialog = document.getElementById('nsfw-report-dialog-overlay');
        if (existingDialog) existingDialog.remove();

        const overlay = document.createElement('div');
        overlay.id = 'nsfw-report-dialog-overlay';

        const dialogHTML = `
            <div id="nsfw-report-dialog">
                <div class="nsfw-report-dialog-header">报告确认</div>
                <div class="nsfw-report-dialog-content">
                    <p>当前判定: <strong>${currentStatusText}</strong></p>
                    <p>点击进行报告,达到3人报告将被确认</p>
                </div>
                <div class="nsfw-report-dialog-footer">
                    <button id="nsfw-report-cancel">取消</button>
                    <button id="nsfw-report-safe" class="inputBtn" style="background-color: #43A047; color: white; border-color: #43A047;">报告为 SFW</button>
                    <button id="nsfw-report-nsfw" class="inputBtn" style="background-color: #E53935; color: white; border-color: #E53935;">报告为 NSFW</button>
                </div>
            </div>
        `;
        overlay.innerHTML = dialogHTML;
        document.body.appendChild(overlay);
        document.body.style.overflow = 'hidden';

        const closeDialog = () => {
            overlay.remove();
            document.body.style.overflow = '';
        };

        overlay.querySelector('#nsfw-report-nsfw').onclick = () => {
            reportImage(imageUrl, 'nsfw', reportBarElement);
            closeDialog();
        };
        overlay.querySelector('#nsfw-report-safe').onclick = () => {
            reportImage(imageUrl, 'safe', reportBarElement);
            closeDialog();
        };
        overlay.querySelector('#nsfw-report-cancel').onclick = closeDialog;
        overlay.onclick = (e) => { if (e.target === overlay) closeDialog(); };
    }

    async function analyzeAndFinalizeImage(imgElement, wrapper, originalSrc) {
        const reportBar = document.createElement('div');
        reportBar.className = 'nsfw-report-bar';

        try {
            wrapper.appendChild(reportBar);
            const payload = JSON.stringify({ image_url: originalSrc });
            const response = await fetchWithTimeout(PROCESS_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
            if (!response.ok) { const errText = await response.text(); throw new Error(`Backend request failed (${response.status}): ${errText}`); }
            const predictions = await response.json();
            if (predictions.error) { throw new Error(`Backend error: ${predictions.error}`); }

            let isNsfw = false;
            let shortClassificationText = '';
            let longClassificationText = '';

            if (predictions.override) {
                isNsfw = (predictions.override === 'nsfw');
                const resultText = isNsfw ? 'NSFW' : 'SFW';
                shortClassificationText = `社区判定: ${resultText}`;
                longClassificationText = shortClassificationText;
            } else if (typeof predictions === 'object' && predictions !== null) {
                let highestNsfwScore = 0;
                let highestNsfwClass = '';
                for (const className in NSFW_THRESHOLDS) {
                    const apiResponseKey = className.toLowerCase();
                    if (predictions.hasOwnProperty(apiResponseKey) && typeof predictions[apiResponseKey] === 'number' && predictions[apiResponseKey] >= NSFW_THRESHOLDS[className]) {
                        isNsfw = true;
                        if (predictions[apiResponseKey] > highestNsfwScore) {
                            highestNsfwScore = predictions[apiResponseKey];
                            highestNsfwClass = className;
                        }
                    }
                }
                if (isNsfw) {
                    shortClassificationText = `自动判定: NSFW`;
                    longClassificationText = `自动判定: NSFW (${highestNsfwClass}: ${(highestNsfwScore * 100).toFixed(1)}%)`;
                } else {
                    shortClassificationText = '自动判定: SFW';
                    longClassificationText = shortClassificationText;
                }
            }

            if (shortClassificationText) {
                reportBar.innerHTML = `<span class="report-msg clickable" title="点击可重新报告此图片">${shortClassificationText}</span>`;
                reportBar.addEventListener('click', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    showReportDialog(originalSrc, longClassificationText, reportBar);
                });
            } else {
                reportBar.remove();
            }

            if (isNsfw) {
                const nsfwLabel = document.createElement('div');
                nsfwLabel.className = 'nsfw-indicator';
                nsfwLabel.textContent = 'NSFW';
                wrapper.appendChild(nsfwLabel);

                wrapper.classList.add('clickable');
                wrapper.title = `${longClassificationText}. Click to view.`;
                wrapper.addEventListener('click', function unblurImage(e) {
                    if (e.target.closest('.nsfw-report-bar')) return;
                    imgElement.classList.remove('nsfw-blurred');
                    imgElement.classList.add('unlocked');
                    wrapper.classList.remove('clickable');
                    wrapper.title = longClassificationText;

                    const label = wrapper.querySelector('.nsfw-indicator');
                    if (label) {
                        label.remove();
                    }
                }, { once: true });
            } else {
                imgElement.classList.remove('nsfw-blurred');
                wrapper.title = longClassificationText;
            }

        } catch (error) {
            console.error('Bangumi NSFW Filter: Error processing image', originalSrc, error.name === 'AbortError' ? 'Request Timeout' : error.message);
            if (wrapper) wrapper.title = `Error: ${error.name === 'AbortError' ? 'Request Timeout' : (error.message || 'Could not process image').substring(0, 100)}`;
            reportBar.remove();
            imgElement.classList.remove('nsfw-blurred');
        } finally {
            const loader = wrapper.querySelector('.nsfw-loader');
            if (loader) {
                loader.remove();
            }
        }
    }

    function findAndHandleImageElement(img) {
        if (!isImageEligible(img)) return;
        img.dataset.nsfwProcessed = 'true';

        const wrapper = document.createElement('div');
        wrapper.className = 'nsfw-image-wrapper';

        const loader = document.createElement('div');
        loader.className = 'nsfw-loader';
        wrapper.appendChild(loader);

        img.classList.add('nsfw-image-element', 'nsfw-blurred');

        if (img.parentNode) img.parentNode.insertBefore(wrapper, img);
        wrapper.appendChild(img);

        const handleImageError = () => cleanupAndFinalize(img, wrapper);
        img.onerror = handleImageError;

        const startAnalysis = () => {
             if (img.naturalWidth > 0 && (img.naturalWidth <= 16 && img.naturalHeight <= 16)) {
                 cleanupAndFinalize(img, wrapper);
                 img.classList.remove('nsfw-blurred');
             } else {
                 analyzeAndFinalizeImage(img, wrapper, img.src);
             }
        };

        if (img.complete) {
            startAnalysis();
        } else {
            img.onload = startAnalysis;
        }
    }

    let observer;
    function observeDOMChanges() {
        if (observer) observer.disconnect();
        observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.addedNodes) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.tagName === 'IMG') findAndHandleImageElement(node);
                            else node.querySelectorAll('img').forEach(findAndHandleImageElement);
                        }
                    }
                }
            }
        });
        observer.observe(document.getElementById('main') || document.body, { childList: true, subtree: true });
    }

    function initialScan() {
        document.querySelectorAll('img').forEach(findAndHandleImageElement);
    }

    function runFilterLogic() {
        const isEnabled = localStorage.getItem(STORAGE_KEY_SCRIPT_ENABLED) !== 'false';
        if (!isEnabled) {
            console.log('Bangumi NSFW Filter: Script is disabled by user setting.');
            return;
        }

        addGlobalStyle(allCSS);
        initialScan();
        observeDOMChanges();
    }

    loadNsfwThresholds();
    function scheduleMainLogic() {
        if (window.location.pathname === '/settings/privacy') {
            initSettingsUI();
        } else {
            runFilterLogic();
        }
    }
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        setTimeout(scheduleMainLogic, 200);
    } else {
        window.addEventListener('load', () => setTimeout(scheduleMainLogic, 200), { once: true });
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址