荒らしのお手書きを判定するやつ+α

スレッド内に荒らしのと思われる画像が貼られた場合に判定したり、でかいdelボタンと削除ボタンを出したりする

// ==UserScript==
// @name         荒らしのお手書きを判定するやつ+α
// @namespace    https://github.com/uzuky
// @version      50.2
// @description  スレッド内に荒らしのと思われる画像が貼られた場合に判定したり、でかいdelボタンと削除ボタンを出したりする
// @author       uzuky (modified by Gemini)
// @license      MIT
// @match        https://*.2chan.net/*
// @match        http://*.2chan.net/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_VERSION = '50.2';

    // ------------------- 設定項目 -------------------
    const QUEUE_DELAY_MIN_MS = 2500; // delキューの最低待機時間 (ミリ秒)
    const QUEUE_DELAY_RANDOM_MS = 400; // delキューのランダム待機時間 (ミリ秒) (最低待機時間にこの数値を上限としたランダムな値が加算される)
    const PIXEL_DIFFERENCE_TOLERANCE = 30;           // 左右領域のピクセルの色一致率をどのくらいの誤差まで許容するか
    const EDGE_WIDTH = 20;                           // 左右の端から何ピクセル見るか
    const EDGE_MIN_COLOR_VARIANCE_THRESHOLD = 100;   // 左右領域の分散しきい値 (領域内にどのくらい色が使われているか見てる)
    const EDGE_VARIANCE_SIMILARITY_THRESHOLD = 0.8;  // 左右領域のそれぞれの分散がどのくらい近いか
    const EDGE_MATCH_RATE_THRESHOLD = 15;            // 左右領域の一致率 (これより小さいと荒らしフラッグがひとつ立つ)
    const FLIP_BOX_SIZE_RATIO = 0.2;                 // 中央領域はどのくらいの範囲を見るか (短辺を基準にした割合)
    const MINIMUM_COLOR_VARIANCE_THRESHOLD = 100;    // 中央領域の分散しきい値 (領域内にどのくらい色が使われているか見てる)
    const FLIP_SIMILARITY_THRESHOLD = 95;            // 中央領域の類似度 (これより大きいと荒らしフラッグがひとつ立つ)
    const MATCH_OVERLAY_BG_COLOR = 'rgba(255, 255, 255, 0.5)';
    const FORM_OVERLAY_BG_COLOR = 'rgba(255, 255, 255, 0.75)';

    // --- 定数定義 ---
    const CONSTANTS = {
        ATTR_PROCESSED: 'data-analysis-processed',
        ATTR_TROLL_FLAG: 'troll',
        ATTR_IS_MATCH: 'data-is-match',
        ATTR_REVERT_STATUS: 'data-revert-status',
        SELECTOR_CONTAINER: 'td.rtd',
        SELECTOR_POST_ID: 'span.cno',
        SELECTOR_IMAGE: 'img',
        SELECTOR_DEL_BUTTON: '.del-reserve-button',
        SELECTOR_PROGRESS_BAR: '.del-progress-bar',
        SELECTOR_THREAD_CONTAINER: 'div.thre',
        SELECTOR_FORM_TABLE: '#ftbl',
        CLASS_INFO_CONTAINER: 'analysis-info-container',
        CLASS_INFO_TEXT: 'analysis-info-text',
        CLASS_MATCH_OVERLAY: 'match-overlay',
        CLASS_MATCH_CONTROLS_WRAPPER: 'match-controls-wrapper',
        CLASS_MATCH_CONTROLS: 'match-controls',
        CLASS_DEL_BUTTON_TEXT: 'del-button-text',
        CLASS_REVERT_BUTTON: 'revert-button',
        ID_TOOLTIP: 'analysis-tooltip',
        ID_FORM_OVERLAY: 'form-del-overlay',
        TEXT_DEL_RESERVE: 'del予約に入れる',
        TEXT_DEL_CANCEL: 'del予約キャンセル',
        TEXT_DELLED: 'del済み',
        TEXT_REVERT: '表示を戻す',
        LOCAL_STORAGE_KEY: 'delHistory',
    };

    // --- CSS定義 ---
    const styles = `
        .${CONSTANTS.CLASS_INFO_CONTAINER} { position: relative; z-index: 2; padding: 1px 0; margin: 0 20px; display: flex; justify-content: left; align-items: center; width: 100%; }
        .${CONSTANTS.CLASS_INFO_TEXT} { font-size: 0.5rem; color: #777; }
        #${CONSTANTS.ID_TOOLTIP} { position: absolute; display: none; padding: 8px; background-color: rgba(0, 0, 0, 0.8); color: white; border: 1px solid #ccc; border-radius: 4px; font-size: 12px; z-index: 9999; pointer-events: none; line-height: 1.6; white-space: pre; }
        .${CONSTANTS.CLASS_MATCH_OVERLAY} { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: ${MATCH_OVERLAY_BG_COLOR}; z-index: 1; pointer-events: none; }
        .${CONSTANTS.CLASS_MATCH_CONTROLS_WRAPPER} { clear:both; position: relative; z-index: 3; width: 100%; }
        .${CONSTANTS.CLASS_MATCH_CONTROLS} { padding: 4px 0; display: flex; justify-content: center; align-items: stretch; gap: 8px; width: 100%; max-width: 445px; }
        .${CONSTANTS.CLASS_REVERT_BUTTON} { position: absolute; top: 5px; right: 8px; color: #666; font-weight: bold; cursor: pointer; font-size: 11px; line-height: 1; z-index: 100; }
        #${CONSTANTS.ID_FORM_OVERLAY} { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: ${FORM_OVERLAY_BG_COLOR}; color: red; font-weight: bold; font-size: 14px; display: flex; justify-content: center; align-items: center; text-align: center; z-index: 100; cursor: not-allowed; }
        .del-reserve-button { position: relative; overflow: hidden; color: white; background-color: red; font-weight: bold; text-decoration: none; cursor: pointer; padding: 3px 8px; border: 1px solid red; border-radius: 3px; font-size: 14px; flex: 2 1 0; display: flex; justify-content: center; align-items: center; }
        .del-reserve-button:hover { background-color: white; color: red; }
        .del-reserve-button.is-canceling { background-color: white; color: red; }
        .del-reserve-button.is-delled { background-color: #ccc; color: #555; cursor: pointer; }
        .${CONSTANTS.SELECTOR_PROGRESS_BAR.substring(1)} { position: absolute; top: 0; right: 0; width: 0%; height: 100%; background-color: #ccc; z-index: 1; }
        .${CONSTANTS.CLASS_DEL_BUTTON_TEXT} { position: relative; z-index: 2; }
        .submit-button { color: white; background-color: purple; font-weight: bold; text-decoration: none; cursor: pointer; padding: 3px 8px; border: 1px solid purple; border-radius: 3px; font-size: 14px; flex: 1 1 0; display: flex; justify-content: center; align-items: center; }
        .submit-button:hover { background-color: white; color: purple; }
        .pwd-wrapper { position: relative; flex: 1 1 0; display: flex; }
        .pwd-input { width: 100%; font-size: 14px; padding: 3px; border: 1px solid #ccc; border-radius: 3px; box-sizing: border-box; }
        .pwd-label { position: absolute; right: 5px; bottom: 2px; font-size: 10px; color: #777; pointer-events: none; }
    `;
    const styleSheet = document.createElement("style");
    styleSheet.innerText = styles;
    document.head.appendChild(styleSheet);

    let delHistory = {};
    let delQueue = [];
    let queueInterval = null;
    let additionCounter = 0;

    const formOverlay = {
        wrapper: null, element: null,
        // 投稿フォームにdel処理中の投稿を制限するオーバーレイを作成・初期化する
        create() {
            const formTable = document.querySelector(CONSTANTS.SELECTOR_FORM_TABLE); if (!formTable) return;
            this.wrapper = document.createElement('div'); this.wrapper.style.position = 'relative';
            formTable.parentNode.insertBefore(this.wrapper, formTable); this.wrapper.appendChild(formTable);
            this.element = document.createElement('div'); this.element.id = CONSTANTS.ID_FORM_OVERLAY; this.element.style.display = 'none';
            this.element.textContent = 'サーバーに怒られるので、del処理が終わって2秒くらい経つまで投稿できません';
            this.wrapper.appendChild(this.element);
        },
        // delキューの状況に応じて投稿フォームのオーバーレイ表示を更新する
        update() {
            if (!this.wrapper) this.create(); if (!this.wrapper) return;
            this.element.style.display = (delQueue.length >= 2) ? 'flex' : 'none';
        }
    };

    // 画像データの色分散(色のばらつき具合)を計算する
    function calculateColorVariance(d) {
        const data = d.data, totalPixels = d.width * d.height; if (totalPixels === 0) return 0;
        let sumR = 0, sumG = 0, sumB = 0;
        for (let i = 0; i < data.length; i += 4) { sumR += data[i]; sumG += data[i + 1]; sumB += data[i + 2]; }
        const avgR = sumR / totalPixels, avgG = sumG / totalPixels, avgB = sumB / totalPixels;
        let varR = 0, varG = 0, varB = 0;
        for (let i = 0; i < data.length; i += 4) { varR += Math.pow(data[i] - avgR, 2); varG += Math.pow(data[i + 1] - avgG, 2); varB += Math.pow(data[i + 2] - avgB, 2); }
        return (varR / totalPixels + varG / totalPixels + varB / totalPixels) / 3;
    }
    // 2つの画像データのピクセルがどの程度一致するかを計算する
    function calculatePixelMatchRate(d1, d2, tolerance) {
        if (!d1 || !d2 || d1.width !== d2.width || d1.height !== d2.height) return 0;
        const data1 = d1.data, data2 = d2.data, totalPixels = d1.width * d1.height; let samePixelCount = 0;
        for (let i = 0; i < data1.length; i += 4) { const diff = Math.abs(data1[i] - data2[i]) + Math.abs(data1[i + 1] - data2[i + 1]) + Math.abs(data1[i + 2] - data2[i + 2]); if (diff <= tolerance) samePixelCount++; }
        return totalPixels === 0 ? 100 : (samePixelCount / totalPixels) * 100;
    }
    // 2つの画像データの色の類似度を計算する
    function calculateColorSimilarity(d1, d2) {
        if (!d1 || !d2 || d1.width !== d2.width || d1.height !== d2.height) return 0;
        const data1 = d1.data, data2 = d2.data; let diff = 0;
        for (let i = 0; i < data1.length; i += 4) { diff += Math.abs(data1[i] - data2[i]) + Math.abs(data1[i + 1] - data2[i + 1]) + Math.abs(data1[i + 2] - data2[i + 2]); }
        const maxDiff = d1.width * d1.height * 255 * 3;
        return maxDiff === 0 ? 100 : 100 - (diff / maxDiff) * 100;
    }
    // 画像の対称性を非同期で解析し、荒らし画像の可能性を判定する
    async function analyzeImageSymmetry(imgElement) {
        return new Promise((resolve, reject) => {
            const img = new Image(); img.crossOrigin = "Anonymous";
            img.onload = () => {
                const w = img.width, h = img.height; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
                const edgeW = Math.min(EDGE_WIDTH, Math.floor(w / 2));
                const leftEdgeData = ctx.getImageData(0, 0, edgeW, h); const rightEdgeData = ctx.getImageData(w - edgeW, 0, edgeW, h);
                const leftVariance = calculateColorVariance(leftEdgeData); const rightVariance = calculateColorVariance(rightEdgeData);
                const leftPassesVariance = leftVariance >= EDGE_MIN_COLOR_VARIANCE_THRESHOLD, rightPassesVariance = rightVariance >= EDGE_MIN_COLOR_VARIANCE_THRESHOLD;
                let varianceRatio = 'N/A', passesRelativeVarianceTest = false;
                if (leftPassesVariance && rightPassesVariance) { varianceRatio = Math.min(leftVariance, rightVariance) / Math.max(leftVariance, rightVariance); passesRelativeVarianceTest = varianceRatio >= EDGE_VARIANCE_SIMILARITY_THRESHOLD; }
                let edgeMatchRate = 'N/A';
                if (passesRelativeVarianceTest) { edgeMatchRate = calculatePixelMatchRate(leftEdgeData, rightEdgeData, PIXEL_DIFFERENCE_TOLERANCE); }
                const shortSide = Math.min(w, h), boxSide = Math.floor(shortSide * FLIP_BOX_SIZE_RATIO);
                const roiX = Math.floor((w - boxSide) / 2), roiY = Math.floor((h - boxSide) / 2), roiW = boxSide, roiH = boxSide;
                const originalRoiData = ctx.getImageData(roiX, roiY, roiW, roiH);
                const centralVariance = calculateColorVariance(originalRoiData); let flipSimilarity = 'N/A';
                if (centralVariance >= MINIMUM_COLOR_VARIANCE_THRESHOLD) {
                    const canvasFlipped = document.createElement('canvas'); canvasFlipped.width = roiW; canvasFlipped.height = roiH;
                    const ctxFlipped = canvasFlipped.getContext('2d'); ctxFlipped.translate(roiW, 0); ctxFlipped.scale(-1, 1); ctxFlipped.drawImage(img, roiX, roiY, roiW, roiH, 0, 0, roiW, roiH);
                    const flippedRoiData = ctxFlipped.getImageData(0, 0, roiW, roiH);
                    flipSimilarity = calculateColorSimilarity(originalRoiData, flippedRoiData);
                }
                resolve({ leftPassesVariance, rightPassesVariance, leftVariance, rightVariance, passesRelativeVarianceTest, varianceRatio, edgeMatchRate, flipSimilarity, centralVariance });
            };
            img.onerror = () => reject(new Error('Image load error'));
            img.src = imgElement.src;
        });
    }

    // 画像解析情報を表示するためのコンテナを作成する
    function createInfoContainer() {
        const infoContainer = document.createElement('div');
        infoContainer.className = CONSTANTS.CLASS_INFO_CONTAINER;
        const infoText = document.createElement('div');
        infoText.className = CONSTANTS.CLASS_INFO_TEXT;
        infoText.textContent = 'Altを押しながらレスにカーソルを合わせると情報を表示';
        infoContainer.appendChild(infoText);
        return infoContainer;
    }

    // del予約(通報)を行うためのボタンを生成する
    function createDelReserveButton(postId, container) {
        const delButton = document.createElement('a'); delButton.href = 'javascript:void(0);'; delButton.className = CONSTANTS.SELECTOR_DEL_BUTTON.substring(1);
        const progressBarFill = document.createElement('div'); progressBarFill.className = CONSTANTS.SELECTOR_PROGRESS_BAR.substring(1);
        const buttonText = document.createElement('span'); buttonText.className = CONSTANTS.CLASS_DEL_BUTTON_TEXT; buttonText.textContent = CONSTANTS.TEXT_DEL_RESERVE;
        delButton.append(progressBarFill, buttonText);
        delButton.addEventListener('click', (e) => {
            e.preventDefault(); e.stopPropagation();
            const existingItemIndex = delQueue.findIndex(item => item.postId === postId);
            if (existingItemIndex > -1) {
                delQueue.splice(existingItemIndex, 1); clearTimeout(queueInterval);
                queueInterval = null;
                if (delQueue.length > 0) { startNextQueueItem(); } else { additionCounter = 0; }
                progressBarFill.style.transition = 'none'; progressBarFill.style.width = '0%';
                if (delHistory[postId]) {
                    markAsDelled(container);
                } else {
                    buttonText.textContent = CONSTANTS.TEXT_DEL_RESERVE;
                    delButton.classList.remove('is-canceling');
                }
            } else {
                delQueue.push({ postId, container }); additionCounter++;
                buttonText.textContent = CONSTANTS.TEXT_DEL_CANCEL;
                delButton.classList.remove('is-delled');
                delButton.classList.add('is-canceling');
                delButton.removeEventListener('mouseover', showRedelTooltip);
                delButton.removeEventListener('mouseout', hideAnalysisTooltip);
                if (delQueue.length === 1) {
                    startNextQueueItem();
                }
            }
            formOverlay.update();
        });
        return delButton;
    }

    // スレッド主が手動でレスを削除するためのUIを作成する
    function createManualDeleteUI(postId) {
        const submitBtn = document.createElement('a'); submitBtn.textContent = '削除'; submitBtn.href = 'javascript:void(0);'; submitBtn.className = 'submit-button';
        const pwdWrapper = document.createElement('div'); pwdWrapper.className = 'pwd-wrapper';
        const pwdInput = document.createElement('input'); pwdInput.type = 'password'; pwdInput.placeholder = ' '; pwdInput.className = 'pwd-input';
        if (typeof window.getCookie === 'function') { pwdInput.value = window.getCookie("pwdc") || ""; }
        pwdInput.addEventListener('click', (e) => e.stopPropagation());
        const pwdLabel = document.createElement('label'); pwdLabel.textContent = '削除キー'; pwdLabel.className = 'pwd-label';
        pwdWrapper.append(pwdInput, pwdLabel);
        submitBtn.addEventListener('click', function(e) {
            e.stopPropagation();
            const password = pwdInput.value, board = window.b || null;
            if (!board) { console.log('板情報(b)が取得できませんでした。'); return; }
            const data = { responsemode: "ajax", pwd: password, onlyimgdel: "", mode: "usrdel" }; data[postId] = "delete";
            const xmlhttp = new XMLHttpRequest(); xmlhttp.open("POST", "/" + board + "/futaba.php?guid=on");
            xmlhttp.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
            xmlhttp.onreadystatechange = function() {
                if (xmlhttp.readyState == 4) {
                    if (xmlhttp.responseText === "ok") {
                        const threEl = document.querySelector(`.${CONSTANTS.SELECTOR_THREAD_CONTAINER.substring(1)}[data-res]`);
                        const threadNo = threEl ? threEl.dataset.res : null;
                        if (threadNo && typeof window.replaceRes === 'function') { window.replaceRes(threadNo, postId); } else { location.reload(); }
                    } else { alert(xmlhttp.responseText); }
                }
            };
            xmlhttp.send(new URLSearchParams(data).toString());
        });
        return { submitBtn, pwdWrapper };
    }

    // 荒らし判定された画像の表示を元に戻すボタンを作成する
    function createRevertButton(container, img) {
        const closeButton = document.createElement('div'); closeButton.textContent = CONSTANTS.TEXT_REVERT; closeButton.className = CONSTANTS.CLASS_REVERT_BUTTON;
        closeButton.addEventListener('mouseover', showRevertTooltip); closeButton.addEventListener('mouseout', hideAnalysisTooltip);
        closeButton.addEventListener('click', function(e) {
            e.preventDefault(); e.stopPropagation(); hideAnalysisTooltip();
            container.querySelector(`.${CONSTANTS.CLASS_MATCH_OVERLAY}`)?.remove();
            container.querySelector(`.${CONSTANTS.CLASS_MATCH_CONTROLS_WRAPPER}`)?.remove();
            container.removeAttribute(CONSTANTS.ATTR_TROLL_FLAG);
            if (img) { img.dataset.revertStatus = (img.dataset.isMatch === 'true' ? '誤判定' : '手動で表示キャンセル') + 'のため表示を戻しました'; }
            closeButton.remove();
            if (img && img.dataset.isMatch === 'false') { container.appendChild(createInfoContainer()); }
        });
        return closeButton;
    }

    const tooltip = document.createElement('div'); tooltip.id = CONSTANTS.ID_TOOLTIP; document.body.appendChild(tooltip);

    // Altキーを押しながらレスにカーソルを合わせた際に画像解析情報のツールチップを表示する
    function showAnalysisTooltip(event) {
        if (!event.altKey) return;
        const container = event.currentTarget, img = container.querySelector(CONSTANTS.SELECTOR_IMAGE);
        if (!img || !img.dataset.leftVariance) { // 解析情報がない場合はツールチップ表示しない
            hideAnalysisTooltip(); return;
        }
        let text = `--- 左右の判定 ---\n` + `分散 (L/R): ${img.dataset.leftVariance} / ${img.dataset.rightVariance}\n` + `分散類似度: ${img.dataset.varianceRatio}\n` + `ピクセル一致率: ${img.dataset.edgeMatchRate}\n` + `--- 中央の判定 ---\n` + `分散: ${img.dataset.centralVariance}\n` + `類似度: ${img.dataset.flipSimilarity}`;
        if (img.dataset.revertStatus) { text += `\n--- 状態 ---\n${img.dataset.revertStatus}`; }
        if (container.hasAttribute(CONSTANTS.ATTR_TROLL_FLAG)) { text += `\n\n[Alt+クリックでdel予約/キャンセル]`; } else { text += `\n\n[Alt+クリックでdel/削除ボタンを表示]`; }
        tooltip.textContent = text; tooltip.style.left = (event.pageX + 15) + 'px'; tooltip.style.top = (event.pageY + 15) + 'px'; tooltip.style.display = 'block';
    }

    // 「表示を戻す」ボタンにカーソルを合わせた際に説明ツールチップを表示する
    function showRevertTooltip(event) {
        tooltip.textContent = `誤判定または手動表示をキャンセルして\n見た目だけもとに戻します (v${SCRIPT_VERSION})`;
        tooltip.style.left = (event.pageX + 15) + 'px'; tooltip.style.top = (event.pageY + 15) + 'px'; tooltip.style.display = 'block';
    }

    // del済みのボタンにカーソルを合わせた際に再delを促すツールチップを表示する
    function showRedelTooltip(event) {
        tooltip.textContent = 'クリックするともう一度delを試みます';
        tooltip.style.left = (event.pageX + 15) + 'px'; tooltip.style.top = (event.pageY + 15) + 'px'; tooltip.style.display = 'block';
    }

    // ツールチップを非表示にする
    function hideAnalysisTooltip() { tooltip.style.display = 'none'; }

    // レスコンテナから投稿IDを取得する
    function getPostId(container) {
        const cnoSpan = container.querySelector(CONSTANTS.SELECTOR_POST_ID);
        return cnoSpan ? cnoSpan.textContent.substring(3) : null;
    }

    // レスをdel済みとしてマークし、ボタンの表示を更新する
    function markAsDelled(container) {
        if (!container.hasAttribute(CONSTANTS.ATTR_TROLL_FLAG)) { createDeletionControls(container, null); }
        const delButton = container.querySelector(CONSTANTS.SELECTOR_DEL_BUTTON);
        if (delButton) {
            const postId = getPostId(container);
            if (delQueue.some(item => item.postId === postId)) { return; }
            const buttonText = delButton.querySelector(`.${CONSTANTS.CLASS_DEL_BUTTON_TEXT}`);
            if (buttonText) { buttonText.textContent = CONSTANTS.TEXT_DELLED; }
            delButton.classList.add('is-delled');
            delButton.addEventListener('mouseover', showRedelTooltip);
            delButton.addEventListener('mouseout', hideAnalysisTooltip);
        }
    }

    // 荒らし判定された画像に削除関連の操作UIを作成・表示する
    function createDeletionControls(container, img) {
        if (!container || container.hasAttribute(CONSTANTS.ATTR_TROLL_FLAG)) return;
        container.querySelector(`.${CONSTANTS.CLASS_INFO_CONTAINER}`)?.remove();
        container.setAttribute(CONSTANTS.ATTR_TROLL_FLAG, 'true');
        container.style.position = 'relative';
        const overlay = document.createElement('div'); overlay.className = CONSTANTS.CLASS_MATCH_OVERLAY; container.appendChild(overlay);

        const controlsWrapper = document.createElement('div');
        controlsWrapper.className = CONSTANTS.CLASS_MATCH_CONTROLS_WRAPPER;

        const controlsContainer = document.createElement('div');
        controlsContainer.className = CONSTANTS.CLASS_MATCH_CONTROLS;

        const postId = getPostId(container);
        if (postId) {
            const delReserveButton = createDelReserveButton(postId, container);
            const { submitBtn, pwdWrapper } = createManualDeleteUI(postId);
            controlsContainer.append(delReserveButton, submitBtn, pwdWrapper);
        }

        controlsWrapper.appendChild(controlsContainer);
        container.appendChild(controlsWrapper);

        const revertButton = createRevertButton(container, img);
        container.appendChild(revertButton);
    }

    // Altキーを押しながらレスをクリックした際の処理をする
    function handleImageClick(event) {
        if (!event.altKey) return;
        event.preventDefault(); event.stopPropagation();
        const container = event.currentTarget;
        const img = container.querySelector(CONSTANTS.SELECTOR_IMAGE);
        const postId = getPostId(container);
        if (postId && delHistory[postId]) {
            markAsDelled(container);
        } else {
            if (container.hasAttribute(CONSTANTS.ATTR_TROLL_FLAG)) {
                const delButton = container.querySelector(CONSTANTS.SELECTOR_DEL_BUTTON);
                if (delButton && delButton.textContent !== CONSTANTS.TEXT_DELLED) { delButton.click(); }
            } else {
                if (img) { img.dataset.revertStatus = '手動で荒らし判定しました'; }
                createDeletionControls(container, img);
            }
        }
        hideAnalysisTooltip();
    }

    // ページ内の未処理の画像をスキャンし、対称性分析を実行して荒らし判定をする
    function scanAndJudgeImages() {
        const containers = document.querySelectorAll(`${CONSTANTS.SELECTOR_CONTAINER}:not([${CONSTANTS.ATTR_PROCESSED}="true"])`);
        for (const container of containers) {
            container.setAttribute(CONSTANTS.ATTR_PROCESSED, 'true');
            const postId = getPostId(container);
            if (postId && delHistory[postId]) {
                markAsDelled(container);
                // del済みレスにもイベントリスナーは必要なのでcontinueしない
            }
            container.addEventListener('mouseover', showAnalysisTooltip);
            container.addEventListener('mouseout', hideAnalysisTooltip);
            container.addEventListener('click', handleImageClick);
            const img = container.querySelector(CONSTANTS.SELECTOR_IMAGE);
            if (img && img.src.includes('/b/thumb/')) {
                analyzeImageSymmetry(img).then(results => {
                    if (!container) return;
                    const { passesRelativeVarianceTest, edgeMatchRate, flipSimilarity } = results;
                    const isMatch = passesRelativeVarianceTest && (edgeMatchRate !== 'N/A' && edgeMatchRate <= EDGE_MATCH_RATE_THRESHOLD) && (flipSimilarity !== 'N/A' && flipSimilarity >= FLIP_SIMILARITY_THRESHOLD);
                    img.dataset.leftVariance = results.leftVariance.toFixed(1);
                    img.dataset.rightVariance = results.rightVariance.toFixed(1);
                    img.dataset.varianceRatio = typeof results.varianceRatio === 'number' ? results.varianceRatio.toFixed(2) : results.varianceRatio;
                    img.dataset.edgeMatchRate = typeof results.edgeMatchRate === 'number' ? results.edgeMatchRate.toFixed(1) + '%' : results.edgeMatchRate;
                    img.dataset.centralVariance = results.centralVariance.toFixed(1);
                    img.dataset.flipSimilarity = typeof results.flipSimilarity === 'number' ? results.flipSimilarity.toFixed(1) + '%' : results.flipSimilarity;
                    img.dataset.isMatch = isMatch.toString();
                    if (isMatch) {
                        createDeletionControls(container, img);
                    } else {
                        container.appendChild(createInfoContainer());
                    }
                }).catch(error => console.error('Symmetry analysis failed:', error, img.src));
            }
        }
    }

    // delキューの次のアイテムの処理を開始し、プログレスバーを更新する
    function startNextQueueItem() {
        if (delQueue.length === 0) return;
        const bonusDelay = Math.floor(additionCounter / 3) * 250;
        const nextDelay = Math.random() * QUEUE_DELAY_RANDOM_MS + QUEUE_DELAY_MIN_MS + bonusDelay;
        queueInterval = setTimeout(() => processDelQueue(nextDelay), nextDelay);
        const nextItem = delQueue[0];
        const nextButton = nextItem.container.querySelector(CONSTANTS.SELECTOR_DEL_BUTTON);
        if (nextButton) {
            const progressBarFill = nextButton.querySelector(`.${CONSTANTS.SELECTOR_PROGRESS_BAR.substring(1)}`);
            if (progressBarFill) {
                progressBarFill.style.transition = `width ${nextDelay / 1000}s linear`;
                progressBarFill.style.width = '100%';
            }
        }
    }

    // delキューの先頭アイテムを処理し、delsend関数を呼び出して通報を実行する
    function processDelQueue(waitTime) {
        if (delQueue.length === 0) return;
        const item = delQueue.shift();
        console.log(`[delQueue] ${item.postId} を ${waitTime.toFixed(0)}ms 待機後にdelsendしました。`);
        const processedButton = item.container.querySelector(CONSTANTS.SELECTOR_DEL_BUTTON);
        const progressBarFill = processedButton?.querySelector(CONSTANTS.SELECTOR_PROGRESS_BAR);
        if (progressBarFill) {
            progressBarFill.style.transition = 'none';
            progressBarFill.style.width = '0%';
        }
        if (typeof window.delsend === 'function') {
            const fakeEvent = { target: item.container };
            window.delsend(fakeEvent, item.postId, "110");
        }
        formOverlay.update();
        delHistory[item.postId] = Date.now();
        localStorage.setItem(CONSTANTS.LOCAL_STORAGE_KEY, JSON.stringify(delHistory));
        markAsDelled(item.container);
        if (delQueue.length === 0) { additionCounter = 0; } else { startNextQueueItem(); }
    }

    // ローカルストレージから古いdel履歴を削除する
    function cleanupDelHistory() {
        const now = Date.now();
        const sixHoursAgo = now - (6 * 60 * 60 * 1000);
        const storedHistory = localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEY);
        if (storedHistory) {
            delHistory = JSON.parse(storedHistory);
            Object.keys(delHistory).forEach(postId => {
                if (delHistory[postId] < sixHoursAgo) { delete delHistory[postId]; }
            });
            localStorage.setItem(CONSTANTS.LOCAL_STORAGE_KEY, JSON.stringify(delHistory));
        }
    }

    // --- 実行と監視 ---
    cleanupDelHistory();
    scanAndJudgeImages();
    const observer = new MutationObserver(scanAndJudgeImages);
    observer.observe(document.body, { childList: true, subtree: true });
})();