AIMG Auto Image Metadata Scanner

画像メタデータのオンデマンド解析、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能、フィルタリング付き一括ダウンロード機能を提供します

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         AIMG Auto Image Metadata Scanner
// @name:ja      AIMG 自動メタデータスキャナー
// @namespace    https://nijiurachan.net/pc/catalog.php
// @version      1.6
// @description  画像メタデータのオンデマンド解析、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能、フィルタリング付き一括ダウンロード機能を提供します
// @author       doridoridorin
// @match        https://nijiurachan.net/pc/thread.php?id=*
// @require      https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// @require      https://unpkg.com/[email protected]/umd/index.js
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. 設定・定数定義
    // ==========================================
    // NovelAIのステルス画像(PNGデータ内に埋め込まれた情報)を識別するためのマジックナンバー
    const NOVELAI_MAGIC = "stealth_pngcomp";

    // 解析対象とする画像の拡張子(正規表現)。PNG, WebP, JPEGに対応
    const TARGET_EXTENSIONS = /\.(png|webp|jpe?g)$/i;

    // 同時に実行するHTTPリクエストの最大数。サーバー負荷軽減とブラウザの通信詰まり防止用
    const MAX_CONCURRENT_REQUESTS = 3;

    // カーテン設定用
    // 対象とするキーワード
    const TARGET_KEYWORDS = ['注意', 'グロ'];

    // カーテン(マスク)のスタイル定義
    const CURTAIN_STYLE = `
        .tm-warning-curtain {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(128, 128, 128, 1); /* 濃い灰色 */
            display: flex;
            justify-content: center;
            align-items: center;
            color: #fff;
            font-weight: bold;
            font-size: 14px;
            cursor: pointer;
            z-index: 1000;
            border-radius: 4px;
        }
        /* 親のAタグに必要なスタイル */
        .tm-relative-anchor {
            position: relative !important;
            display: inline-block !important; /* 画像サイズに合わせるため */
        }
    `;

    // ==========================================
    // 2. ユーティリティ (コピー・並列処理)
    // ==========================================

    /**
     * テキストをクリップボードにコピーする関数
     * 環境に応じて最適な方法(GM_setClipboard > navigator.clipboard > execCommand)を自動選択します
     */
    function copyToClipboard(text) {
        // 1. Tampermonkey等の特権関数があれば最優先で使用
        if (typeof GM_setClipboard === 'function') {
            GM_setClipboard(text);
            return;
        }
        // 2. モダンブラウザの標準APIを使用
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).catch(err => {
                fallbackCopyTextToClipboard(text); // 失敗時はフォールバック
            });
            return;
        }
        // 3. 古い手法へフォールバック
        fallbackCopyTextToClipboard(text);
    }

    /**
     * コピー機能のフォールバック用関数(非SSL環境や古いブラウザ向け)
     * 画面外にtextareaを作成し、選択してコピーコマンドを実行します
     */
    function fallbackCopyTextToClipboard(text) {
        const textArea = document.createElement("textarea");
        textArea.value = text;
        textArea.style.position = "fixed";
        textArea.style.opacity = "0";
        document.body.appendChild(textArea);
        textArea.focus();
        textArea.select();
        try { document.execCommand('copy'); } catch (err) {}
        document.body.removeChild(textArea);
    }

    /**
     * 並列処理の同時実行数を制限する関数 (Promise.allの制限版)
     * 一括ダウンロードや一括解析時に、サーバーへ大量のリクエストが一気に飛ばないように制御します
     * @param {Array} items - 処理対象のリスト
     * @param {Function} iterator - 各要素を実行する非同期関数
     * @param {Number} concurrency - 同時実行数
     */
    async function pMap(items, iterator, concurrency) {
        const results = [];
        const executing = [];
        for (const item of items) {
            const p = Promise.resolve().then(() => iterator(item));
            results.push(p);
            const e = p.then(() => executing.splice(executing.indexOf(e), 1));
            executing.push(e);
            if (executing.length >= concurrency) await Promise.race(executing);
        }
        return Promise.all(results);
    }

    // ==========================================
    // 3. リクエストキュー管理
    // ==========================================
    // 個別のメタデータ解析リクエストを管理するためのキュー

    const requestQueue = [];
    let activeRequests = 0;

    /**
     * キューからタスクを取り出して実行する再帰的関数
     */
    function processQueue() {
        if (activeRequests >= MAX_CONCURRENT_REQUESTS || requestQueue.length === 0) return;
        const task = requestQueue.shift();
        activeRequests++;
        task.onStart(); // ボタンの表示を「解析中」に変更

        // GM_xmlhttpRequestを使用して画像データをバイナリ(ArrayBuffer)として取得
        // 通常のfetchではCORS(クロスドメイン)制約により、外部サイトの画像をCanvasで操作できないため必須
        GM_xmlhttpRequest({
            method: "GET",
            url: task.url,
            responseType: "arraybuffer",
            onload: async (response) => {
                try {
                    // データ取得成功時に解析を実行
                    const results = await analyzeImage(response.response);
                    task.onSuccess(results);
                } catch (e) {
                    task.onError(e);
                } finally {
                    activeRequests--;
                    processQueue(); // 次のタスクへ
                }
            },
            onerror: (err) => {
                task.onError(err);
                activeRequests--;
                processQueue();
            }
        });
    }

    /**
     * 新しい解析タスクをキューに追加する関数
     */
    function addToQueue(url, callbacks) {
        requestQueue.push({ url, ...callbacks });
        processQueue();
    }

    // ==========================================
    // 4. 解析ロジック (LSB, Exif)
    // ==========================================

    /**
     * LSB (Least Significant Bit) 解析用クラス
     * 画像ピクセルのアルファチャンネルの最下位ビットに隠されたデータを読み取ります
     */
    class LSBExtractor {
        constructor(pixels, width, height) {
            this.pixels = pixels; this.width = width; this.height = height;
            this.bitsRead = 0; this.currentByte = 0; this.row = 0; this.col = 0;
        }

        // 次の1ビットを取得(NovelAIは列優先[Column-Major]でデータを埋め込む仕様)
        getNextBit() {
            if (this.col >= this.width) return null;
            const pixelIndex = (this.row * this.width + this.col) * 4; // RGBAなので4倍
            const bit = this.pixels[pixelIndex + 3] & 1; // Alphaチャンネル(+3)のLSBを取得
            this.row++;
            if (this.row >= this.height) { this.row = 0; this.col++; } // 端まで行ったら次の列へ
            return bit;
        }

        // 8ビット集めて1バイトを生成
        getOneByte() {
            this.bitsRead = 0; this.currentByte = 0;
            while (this.bitsRead < 8) {
                const bit = this.getNextBit();
                if (bit === null) return null;
                this.currentByte = (this.currentByte << 1) | bit;
                this.bitsRead++;
            }
            return this.currentByte;
        }

        // 指定バイト数分読み込む
        getNextNBytes(n) {
            const bytes = new Uint8Array(n);
            for (let i = 0; i < n; i++) {
                const byte = this.getOneByte();
                if (byte === null) throw new Error("LSB: End of data");
                bytes[i] = byte;
            }
            return bytes;
        }

        // 32ビット整数(ビッグエンディアン)を読み込む(データ長取得用)
        readUint32BE() {
            const bytes = this.getNextNBytes(4);
            const view = new DataView(bytes.buffer);
            return view.getUint32(0, false);
        }
    }

    // ファイルシグネチャ(マジックバイト)定義
    const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
    const WEBP_SIGNATURE = [82, 73, 70, 70]; // "RIFF"

    function checkSignature(data, signature) {
        if (data.length < signature.length) return false;
        for (let i = 0; i < signature.length; i++) {
            if (data[i] !== signature[i]) return false;
        }
        return true;
    }

    // BlobデータをCanvasに描画してピクセルデータを取得する
    async function getPixelsFromBlob(blob) {
        return new Promise((resolve, reject) => {
            const url = URL.createObjectURL(blob);
            const img = new Image();
            img.crossOrigin = "Anonymous";
            img.onload = () => {
                const canvas = document.createElement('canvas');
                canvas.width = img.width; canvas.height = img.height;
                const ctx = canvas.getContext('2d');
                if (!ctx) return reject(new Error('Canvas Context Error'));
                ctx.drawImage(img, 0, 0);
                URL.revokeObjectURL(url);
                try {
                    const imageData = ctx.getImageData(0, 0, img.width, img.height);
                    resolve({ pixels: imageData.data, width: img.width, height: img.height });
                } catch (e) { reject(new Error("CORS or Canvas Error")); }
            };
            img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image Load Error')); };
            img.src = url;
        });
    }

    /**
     * メイン解析関数
     * バイナリデータからExif情報とLSB情報を解析します
     */
    async function analyzeImage(arrayBuffer) {
        const results = [];
        const uint8Data = new Uint8Array(arrayBuffer);

        // ファイルタイプ判定
        let mimeType = '';
        if (checkSignature(uint8Data, PNG_SIGNATURE)) mimeType = 'image/png';
        else if (checkSignature(uint8Data, WEBP_SIGNATURE)) mimeType = 'image/webp';

        // Blob作成(WebPの場合、MIMEタイプを明示しないとImage.srcで読み込めないブラウザがあるため)
        const blob = mimeType ? new Blob([uint8Data], { type: mimeType }) : new Blob([uint8Data]);

        // --- 1. 一般的なメタデータ解析 (ExifReader使用) ---
        try {
            // @ts-ignore
            const tags = ExifReader.load(arrayBuffer, {expanded: true}); // expanded: trueで階層構造化して取得
            const generalData = {};

            // Stable Diffusion (PNGのtEXt: parameters)
            if (tags.parameters?.description) generalData['Stable Diffusion'] = tags.parameters.description;

            // NovelAI (Exif Comment - V3以前の形式)
            if (tags.exif?.Comment?.description) {
                try { generalData['NovelAI (Exif)'] = JSON.parse(tags.exif?.Comment.description); }
                catch (e) { generalData['Comment'] = tags.exif?.Comment.description; }
            }

            // NovelAI (WebP UserComment - V4以降やWebP形式)
            if (tags.exif?.UserComment) {
                // UserCommentはバイナリ形式で来る場合があるためデコードと整形を行う
                const uint8Array = new Uint8Array(tags.exif?.UserComment.value);
                const decoder = new TextDecoder("utf-16");
                const result = decoder.decode(uint8Array.slice(9)); // 先頭のヘッダをスキップ
                let cleanText = result.replace(/^UNICODE\x00/, '').replace(/^\x00+/, ''); // 不要なヌル文字等を除去
                try {
                    // NovelAI固有の署名が含まれているか確認
                    if (cleanText.includes('"signed_hash":')) {
                        const splitText = cleanText.split("Comment: ")
                        const s = splitText[1].indexOf('{');
                        const e = splitText[1].lastIndexOf('}');
                        if (s !== -1 && e !== -1) {
                            generalData['NovelAI (WebP)'] = JSON.parse(splitText[1].substring(s, e + 1));
                        }
                    } else {
                        generalData['UserComment'] = cleanText; // その他のコメント
                    }
                } catch (e) {
                    try{
                        const s = cleanText.indexOf('{');
                        const splitText = cleanText.split(', "signed_hash":');
                        if (s !== -1 && splitText[0]) {
                            generalData['NovelAI (WebP)'] = JSON.parse(splitText[0].substring(s) + "}");
                        }
                    }catch(e) {
                        generalData['UserComment'] = cleanText;
                    }
                }
            }

            // ImageDescription (一般的なフォールバック)
            if (tags.ImageDescription?.description) {
                generalData['ImageDescription'] = tags.ImageDescription.description;
            }

            if (tags.Software) generalData['Software'] = tags.Software.description;

            if (Object.keys(generalData).length > 0) results.push({ type: 'Standard Metadata', content: generalData });
        } catch (e) { /* 解析エラーは無視して続行 */ }

        // --- 2. LSB解析 (NovelAI Stealth / PNG・WebPのみ) ---
        if (mimeType) {
            try {
                const { pixels, width, height } = await getPixelsFromBlob(blob);
                const extractor = new LSBExtractor(pixels, width, height);
                // マジックナンバーを確認
                const magicString = new TextDecoder().decode(extractor.getNextNBytes(NOVELAI_MAGIC.length));
                if (magicString === NOVELAI_MAGIC) {
                    const dataLength = extractor.readUint32BE() / 8; // データ長取得
                    // @ts-ignore
                    const decompressedData = window.pako.inflate(extractor.getNextNBytes(dataLength)); // gzip解凍
                    results.push({ type: 'NovelAI Stealth', content: JSON.parse(new TextDecoder().decode(decompressedData)) });
                }
            } catch (e) { /* LSBデータ無し */ }
        }
        return results;
    }

    /**
     * 抽出されたメタデータからプロンプト情報を整理して返す関数
     * 優先順位: LSB > NovelAI(Exif/WebP) > Stable Diffusion > その他
     */
    function extractPrompts(results) {
        let prompt = ""; let uc = ""; let charPrompt = []; let charUc = []; let found = false; let software = "";

        const lsbData = results.find(r => r.type === 'NovelAI Stealth');
        const standardData = results.find(r => r.type === 'Standard Metadata');

        // NovelAI形式のJSONパース
        const parseNaiJson = (json) => {
            let content = json;
            let s = content.Source || "";
            if (json.Comment && typeof json.Comment === 'string') {
                try { content = JSON.parse(json.Comment); } catch (e) {}
            } else if (json.Comment && typeof json.Comment === 'object') content = json.Comment;

            let p = content.prompt || ""; let u = content.uc || "";
            // V4形式のキャプションオブジェクト対応
            if (!p && content.v4_prompt?.caption?.base_caption) p = content.v4_prompt.caption.base_caption;
            if (!u && content.v4_negative_prompt?.caption?.base_caption) u = content.v4_negative_prompt.caption.base_caption;
            let cp = content.v4_prompt?.caption?.char_captions || [];
            let cu = content.v4_negative_prompt?.caption?.char_captions || [];
            return { p, u, cp, cu, s };
        };

        // Stable Diffusion形式のテキストパース
        const parseSDJson = (json) => {
            const negSplit = json.split(/Negative prompt:/i);
            let p = negSplit[0].trim();
            let u = "";
            if (negSplit[1]) u = negSplit[1].split(/Steps:/i)[0].trim();
            const souSplit = json.split(/Model:/i)[1] || "";
            const s = souSplit.split(",")[0].trim() || ""
            return { p, u, s };
        };

        // 各ソースからのデータ抽出を試行
        if (lsbData && lsbData.content) {
            const extracted = parseNaiJson(lsbData.content);
            prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true;software = extracted.s;
        } else if (standardData && standardData.content) {
            const content = standardData.content;
            if (content['NovelAI (Exif)']) {
                const extracted = parseNaiJson(content['NovelAI (Exif)']);
                prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true;software = extracted.s;
            } else if (content['NovelAI (WebP)']) {
                const extracted = parseNaiJson(content['NovelAI (WebP)']);
                prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true;software = extracted.s;
            } else if (content['Stable Diffusion']) {
                const extracted = parseSDJson(content['Stable Diffusion']);
                prompt = extracted.p; uc = extracted.u;found = true;software = extracted.s;
            } else {
                // 上記以外の汎用タグからのフォールバック
                if (content['UserComment']) {
                    const extracted = parseSDJson(content['UserComment']);
                    prompt = extracted.p; uc = extracted.u;found = true;software = extracted.s;
                } else if (content['ImageDescription']) {
                    prompt = content['ImageDescription'];
                    found = true;software = "Other";
                } else if (content['Comment']) {
                    prompt = content['Comment'];
                    found = true;software="Other";
                }
            }
        }
        return { prompt, uc, found, charPrompt, charUc, software };
    }

    // ==========================================
    // 5. UI コンポーネント
    // ==========================================

    // コピーアイコンの生成 (SVG)
    function createCopyIcon() {
        const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        svg.setAttribute("viewBox", "0 0 24 24");
        svg.setAttribute("width", "14"); svg.setAttribute("height", "14");
        svg.style.fill = "currentColor";
        const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
        path.setAttribute("d", "M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z");
        svg.appendChild(path);
        return svg;
    }

    // 詳細表示ボックスの生成 (タブ切り替え機能付き)
    function createResultDetailBox(results) {
        const container = document.createElement('div');
        Object.assign(container.style, {
            backgroundColor: 'rgba(20, 20, 20, 0.95)', color: '#eee', padding: '10px', fontSize: '12px', marginTop: '4px',
            border: '1px solid #444', borderRadius: '4px', maxWidth: '100%', overflowX: 'auto', textAlign: 'left',
            display: 'none', boxShadow: '0 4px 15px rgba(0,0,0,0.5)'
        });

        const { prompt, uc, found, charPrompt, charUc, software } = extractPrompts(results);

        // --- タブヘッダー ---
        const tabHeader = document.createElement('div');
        tabHeader.style.display = 'flex'; tabHeader.style.borderBottom = '1px solid #555'; tabHeader.style.marginBottom = '10px';

        const createTab = (text, isActive) => {
            const t = document.createElement('div'); t.textContent = text;
            Object.assign(t.style, { padding: '5px 10px', cursor: 'pointer', fontWeight: 'bold', borderBottom: isActive ? '2px solid #4CAF50' : '2px solid transparent', color: isActive ? '#fff' : '#888' });
            return t;
        };

        const tabPrompt = createTab(`📝 Prompt (${software})`, true);
        const tabRaw = createTab('📄 Raw Data', false);
        tabHeader.appendChild(tabPrompt); tabHeader.appendChild(tabRaw); container.appendChild(tabHeader);

        // --- Prompt表示エリア ---
        const viewPrompt = document.createElement('div');
        const createCopyableSection = (label, text) => {
            const wrapper = document.createElement('div'); wrapper.style.marginBottom = '10px';
            const header = document.createElement('div'); header.style.display = 'flex'; header.style.alignItems = 'center'; header.style.marginBottom = '4px';
            const title = document.createElement('span'); title.textContent = label; title.style.fontWeight = 'bold'; title.style.color = '#81C784'; title.style.marginRight = '8px';
            const copyBtn = document.createElement('button'); copyBtn.appendChild(createCopyIcon());
            Object.assign(copyBtn.style, { background: 'transparent', border: '1px solid #666', borderRadius: '3px', color: '#ccc', cursor: 'pointer', padding: '2px 6px', display: 'flex', alignItems: 'center' });

            copyBtn.onclick = (e) => {
                e.preventDefault(); e.stopPropagation(); // 親要素へのクリック伝播を阻止
                copyToClipboard(String(text));
                // コピー成功時の視覚フィードバック
                const originalColor = copyBtn.style.color; copyBtn.style.color = '#4CAF50'; copyBtn.style.borderColor = '#4CAF50';
                setTimeout(() => { copyBtn.style.color = originalColor; copyBtn.style.borderColor = '#666'; }, 1000);
            };
            header.appendChild(title); if (text) header.appendChild(copyBtn);
            const content = document.createElement('div'); content.textContent = text || "(None)";
            Object.assign(content.style, { whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: '6px', backgroundColor: '#000', borderRadius: '3px', border: '1px solid #333', color: text ? '#ddd' : '#666', fontFamily: 'Consolas, monospace', fontSize: '11px', maxHeight: '150px', overflowY: 'auto' });
            wrapper.appendChild(header); wrapper.appendChild(content); return wrapper;
        };

        if (found) {
            viewPrompt.appendChild(createCopyableSection("Prompt", prompt));
            if (uc) viewPrompt.appendChild(createCopyableSection("Negative (UC)", uc));
            for(let i = 0; charPrompt.length > i; i++) {
                viewPrompt.appendChild(createCopyableSection(`CharacterPrompt${i+1}`, charPrompt[i].char_caption || ""));
                viewPrompt.appendChild(createCopyableSection(`CharacterNegative${i+1}`, charUc[i].char_caption || ""));
            }
        } else {
            const noData = document.createElement('div'); noData.textContent = "プロンプト情報を自動抽出できませんでした。Raw Dataを確認してください。"; noData.style.color = '#aaa'; noData.style.padding = '10px';
            viewPrompt.appendChild(noData);
        }
        container.appendChild(viewPrompt);

        // --- Raw Data表示エリア ---
        const viewRaw = document.createElement('div'); viewRaw.style.display = 'none';
        const lsbData = results.find(r => r.type === 'NovelAI Stealth');
        const standardData = results.find(r => r.type === 'Standard Metadata');
        let res = "";
        if (standardData) {
            res = standardData;
        } else if(lsbData) {
            res = lsbData;
        }
        if (standardData || lsbData) {
            const title = document.createElement('div'); title.textContent = `■ ${res.type}`; title.style.color = '#64B5F6'; title.style.fontWeight = 'bold'; title.style.marginTop = '10px'; title.style.borderBottom = '1px solid #444';
            const contentPre = document.createElement('pre'); contentPre.style.whiteSpace = 'pre-wrap'; contentPre.style.wordBreak = 'break-all'; contentPre.style.margin = '5px 0 0 0'; contentPre.style.fontFamily = 'Consolas, monospace';
            contentPre.textContent = typeof res.content === 'object' ? JSON.stringify(res.content, null, 2) : res.content;
            viewRaw.appendChild(title); viewRaw.appendChild(contentPre);
        }
        container.appendChild(viewRaw);

        // タブ切り替え制御
        tabPrompt.onclick = (e) => { e.preventDefault(); e.stopPropagation(); viewPrompt.style.display = 'block'; viewRaw.style.display = 'none'; tabPrompt.style.borderBottomColor = '#4CAF50'; tabPrompt.style.color = '#fff'; tabRaw.style.borderBottomColor = 'transparent'; tabRaw.style.color = '#888'; };
        tabRaw.onclick = (e) => { e.preventDefault(); e.stopPropagation(); viewPrompt.style.display = 'none'; viewRaw.style.display = 'block'; tabRaw.style.borderBottomColor = '#2196F3'; tabRaw.style.color = '#fff'; tabPrompt.style.borderBottomColor = 'transparent'; tabPrompt.style.color = '#888'; };
        return container;
    }

    // ==========================================
    // 6. メイン UI / ロジック (個別解析ボタン)
    // ==========================================
    function attachScanner(anchor) {
        if (anchor.dataset.metaScannerAttached) return;

        const href = anchor.href;
        const childImg = anchor.querySelector('div img');
        if (!href || !TARGET_EXTENSIONS.test(href) || !childImg) return;

        anchor.dataset.metaScannerAttached = "true";

        const uiContainer = document.createElement('div');
        uiContainer.style.display = 'block'; uiContainer.style.marginTop = '2px'; uiContainer.style.textAlign = 'left'; uiContainer.style.lineHeight = '1';

        const btn = document.createElement('button');
        btn.textContent = '🔍 未解析';
        Object.assign(btn.style, {
            fontSize: '11px', padding: '3px 6px', border: 'none', borderRadius: '3px',
            backgroundColor: '#eee', color: '#555', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
        });
        btn.classList.add('meta-scan-btn');
        btn.dataset.status = 'unanalyzed';
        btn.dataset.href = href;

        uiContainer.appendChild(btn);
        if (anchor.nextSibling) anchor.parentNode.insertBefore(uiContainer, anchor.nextSibling);
        else anchor.parentNode.appendChild(uiContainer);

        let detailBox = null;
        let analysisPromise = null;

        // 解析開始処理 (Promiseを返すことで待機可能にする)
        const startAnalysis = () => {
            if (analysisPromise) return analysisPromise;

            analysisPromise = new Promise((resolve) => {
                if (btn.dataset.status === 'analyzed' || btn.dataset.status === 'error') {
                    resolve();
                    return;
                }

                btn.dataset.status = 'analyzing';
                addToQueue(href, {
                    onStart: () => {
                        btn.textContent = '🔄 解析中...';
                        btn.style.backgroundColor = '#FFEB3B';
                        btn.style.color = '#333';
                        btn.style.cursor = 'wait';
                    },
                    onSuccess: (results) => {
                        btn.dataset.status = 'analyzed';
                        if (results.length > 0) {
                            btn.textContent = '✅ メタデータ';
                            btn.style.backgroundColor = '#4CAF50';
                            btn.style.color = 'white';
                            btn.style.cursor = 'pointer';
                            btn.dataset.hasMeta = "true";

                            detailBox = createResultDetailBox(results);
                            uiContainer.appendChild(detailBox);

                            // クリックで詳細ボックスの表示/非表示を切り替え
                            btn.onclick = (e) => {
                                e.preventDefault(); e.stopPropagation();
                                if (detailBox.style.display === 'none') {
                                    detailBox.style.display = 'block';
                                    btn.textContent = '🔼 閉じる';
                                } else {
                                    detailBox.style.display = 'none';
                                    btn.textContent = '✅ メタデータ';
                                }
                            };
                        } else {
                            btn.textContent = '❌ 取得できませんでした';
                            btn.style.backgroundColor = 'transparent';
                            btn.style.color = '#999';
                            btn.style.opacity = '0.5';
                            btn.style.cursor = 'default';
                            btn.dataset.hasMeta = "false";
                        }
                        // ホバーイベントの解除
                        anchor.removeEventListener('mouseenter', startAnalysis);
                        btn.removeEventListener('mouseenter', startAnalysis);
                        resolve();
                    },
                    onError: (err) => {
                        console.error(err);
                        btn.textContent = '⚠️ エラー';
                        btn.style.backgroundColor = '#FFCDD2';
                        btn.style.color = '#D32F2F';
                        btn.dataset.status = 'error';
                        resolve();
                    }
                });
            });
            return analysisPromise;
        };

        btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); startAnalysis(); };
        anchor.addEventListener('mouseenter', startAnalysis); // リンクホバーで解析開始
        btn.addEventListener('mouseenter', startAnalysis); // ボタンホバーで解析開始
        btn.startMetaAnalysis = startAnalysis; // 一括解析用に関数を保持
    }

    // ==========================================
    // 7. グローバルコントロール (一括操作)
    // ==========================================
    function injectGlobalControlButtons() {
        const createContainer = () => {
            const wrapper = document.createElement('div');
            Object.assign(wrapper.style, {
                textAlign: 'center', padding: '10px', backgroundColor: 'rgba(255,255,255,0.05)',
                margin: '10px 0', borderRadius: '5px'
            });
            return wrapper;
        };

        const createButton = (text, color, onClick) => {
            const btn = document.createElement('button');
            btn.textContent = text;
            Object.assign(btn.style, {
                fontSize: '12px', padding: '6px 12px', margin: '0 5px', border: 'none', borderRadius: '4px',
                backgroundColor: color, color: 'white', cursor: 'pointer', fontWeight: 'bold', boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
            });
            btn.onclick = onClick;
            return btn;
        };

        // 一括ダウンロード処理 (fflateを使用)
        const handleBulkDownload = async (onlyAnalyzed) => {
            const btns = Array.from(document.querySelectorAll('button.meta-scan-btn'));
            if (btns.length === 0) {
                alert('対象画像がありません');
                return;
            }

            let message = `${btns.length}枚の画像をダウンロードしますか?`;
            if (onlyAnalyzed) {
                message = `全${btns.length}枚の画像をチェックし、メタデータがある画像のみをダウンロードしますか?\n(未解析の画像は自動的に解析されます)`;
            }
            if (!confirm(message)) return;

            // fflateによるZIP生成のセットアップ
            // @ts-ignore
            const zip = new fflate.Zip();
            const zipData = [];

            // 圧縮データが生成されるたびに呼び出されるコールバック
            zip.ondata = (err, data, final) => {
                if (err) {
                    console.error(err);
                    return;
                }
                zipData.push(data);
                if (final) {
                    // 全ての処理が完了したらBlobを作成してダウンロード
                    const blob = new Blob(zipData, { type: 'application/zip' });
                    const a = document.createElement('a');
                    a.href = URL.createObjectURL(blob);
                    a.download = `images_${Date.now()}.zip`;
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    URL.revokeObjectURL(a.href);

                    if (statusLabel.parentNode) document.body.removeChild(statusLabel);
                }
            };

            const statusLabel = document.createElement('div');
            statusLabel.style.position = 'fixed';
            statusLabel.style.top = '10px'; statusLabel.style.right = '10px';
            statusLabel.style.background = '#333'; statusLabel.style.color = '#fff';
            statusLabel.style.padding = '10px'; statusLabel.style.zIndex = '10000';
            statusLabel.textContent = '準備中...';
            document.body.appendChild(statusLabel);

            try {
                let targetBtns = [...btns];

                // 「メタデータ解析済のみ」モードの場合の事前処理
                if (onlyAnalyzed) {
                    statusLabel.textContent = 'メタデータ解析中...';
                    const unanalyzed = targetBtns.filter(b => b.dataset.status !== 'analyzed' && b.dataset.status !== 'error');

                    // 未解析分を並列処理で解析(サーバー負荷を考慮し制限付き)
                    await pMap(unanalyzed, async (btn) => {
                        if (typeof btn.startMetaAnalysis === 'function') {
                            await btn.startMetaAnalysis();
                        }
                    }, MAX_CONCURRENT_REQUESTS);

                    // メタデータ有りフラグが立っているものだけ抽出
                    targetBtns = targetBtns.filter(b => b.dataset.hasMeta === "true");
                }

                if (targetBtns.length === 0) {
                    alert('ダウンロード対象がありませんでした');
                    document.body.removeChild(statusLabel);
                    return;
                }

                statusLabel.textContent = `${targetBtns.length}枚の画像をダウンロード中...`;

                let processedCount = 0;

                // 画像ダウンロードとZIP追加の並列処理
                await pMap(targetBtns, async (btn) => {
                    const url = btn.dataset.href;
                    if (!url) return;

                    try {
                        const buffer = await new Promise((resolve, reject) => {
                            GM_xmlhttpRequest({
                                method: "GET",
                                url: url,
                                responseType: "arraybuffer",
                                onload: (response) => resolve(new Uint8Array(response.response)),
                                onerror: () => resolve(null)
                            });
                        });

                        if (buffer) {
                            const filename = url.substring(url.lastIndexOf('/') + 1) || `image_${Date.now()}.png`;
                            // ZipPassThroughを使用してデータをストリームに追加
                            // @ts-ignore
                            const file = new fflate.ZipPassThrough(filename);
                            zip.add(file);
                            file.push(buffer, true); // true = 最後のチャンク
                        }

                        processedCount++;
                        statusLabel.textContent = `ダウンロード中... ${processedCount}/${targetBtns.length}`;

                    } catch (e) {
                        console.error(e);
                    }
                }, MAX_CONCURRENT_REQUESTS);

                statusLabel.textContent = 'ZIP生成中...';
                zip.end(); // ストリームの終了を通知

            } catch (e) {
                console.error(e);
                alert('ダウンロード中にエラーが発生しました');
                if (statusLabel.parentNode) document.body.removeChild(statusLabel);
            }
        };

        // コントロールボタン群の描画
        const renderControls = (parent) => {
            const container = createContainer();

            const btnAnalyze = createButton('🚀 全画像を解析', '#2196F3', () => {
                const unanalyzedBtns = document.querySelectorAll('button.meta-scan-btn[data-status="unanalyzed"]');
                if (unanalyzedBtns.length === 0) return alert('未解析の画像はありません');
                if (!confirm(`${unanalyzedBtns.length}枚の画像を解析しますか?`)) return;
                unanalyzedBtns.forEach(b => b.startMetaAnalysis && b.startMetaAnalysis());
            });

            const btnDownload = createButton('💾 一括ダウンロード', '#FF9800', () => {
                const checkbox = container.querySelector('input[type="checkbox"]');
                handleBulkDownload(checkbox.checked);
            });

            const checkLabel = document.createElement('label');
            checkLabel.style.marginLeft = '10px';
            checkLabel.style.fontSize = '12px';
            checkLabel.style.color = '#ccc';
            checkLabel.style.cursor = 'pointer';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.style.marginRight = '5px';

            checkLabel.appendChild(checkbox);
            checkLabel.appendChild(document.createTextNode('メタデータ解析済のみ'));

            container.appendChild(btnAnalyze);
            container.appendChild(btnDownload);
            container.appendChild(checkLabel);

            if (parent.classList.contains('thread-nav-top')) {
                parent.insertAdjacentElement('afterend', container);
            } else {
                parent.insertAdjacentElement('beforebegin', container);
            }
        };

        const topNav = document.querySelector('.thread-nav.thread-nav-top');
        if (topNav) renderControls(topNav);

        const bottomNav = document.querySelector('.thread-nav.thread-nav-bottom');
        if (bottomNav) renderControls(bottomNav);
    }

    // ==========================================
    // 8. カーテン処理
    // ==========================================

    // スタイル適用
    if (typeof GM_addStyle !== 'undefined') {
        GM_addStyle(CURTAIN_STYLE);
    } else {
        const style = document.createElement('style');
        style.textContent = CURTAIN_STYLE;
        document.head.appendChild(style);
    }

    // メイン処理
    function applyCurtain() {
        const anchorTags = document.querySelectorAll('a div img');

        anchorTags.forEach(img => {
            const anchor = img.closest('a');
            if (!anchor) return;

            // 既にカーテンがある(tm-curtain-active)、
            // またはユーザーが一度クリックして解除した(tm-user-revealed)場合はスキップ
            if (anchor.classList.contains('tm-curtain-active') || anchor.classList.contains('tm-user-revealed')) {
                return;
            }

            const parent = anchor.parentElement;
            if (!parent) return;

            // 同階層のblockquoteを探す
            const blockquote = parent.querySelector('blockquote');

            if (blockquote) {
                const text = blockquote.textContent;
                const isTarget = TARGET_KEYWORDS.some(keyword => text.includes(keyword));

                if (isTarget) {
                    createMask(anchor);
                }
            }
        });
    }

    // カーテン作成処理
    function createMask(anchor) {
        // 重複処理防止のフラグ(カーテン適用中)
        anchor.classList.add('tm-curtain-active');
        anchor.classList.add('tm-relative-anchor');

        const mask = document.createElement('div');
        mask.className = 'tm-warning-curtain';
        mask.textContent = 'クリックで表示';

        mask.addEventListener('click', function(e) {
            e.preventDefault();
            e.stopPropagation();

            // カーテン削除
            mask.remove();

            // フラグ更新:適用中を外し、解除済み(revealed)を追加
            anchor.classList.remove('tm-curtain-active');
            anchor.classList.add('tm-user-revealed');
        });

        anchor.appendChild(mask);
    }

    // ==========================================
    // 9. 初期化
    // ==========================================
    function init() {
        // 既存のリンクに対してスキャン
        const anchors = document.querySelectorAll('a');
        anchors.forEach(attachScanner);

        let isScheduled = false;
        // 動的に追加されるコンテンツ(無限スクロール等)を監視
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === 1) {
                        if (node.tagName === 'A') attachScanner(node);
                        else node.querySelectorAll('a').forEach(attachScanner);
                    }
                });
            });
            // すでに次の描画フレームでの実行が予約されていれば何もしない
            if (isScheduled) return;
            isScheduled = true;
            // 次の描画タイミングまで処理を待機(間引き処理)
            requestAnimationFrame(() => {
                // カーテン処理実行
                applyCurtain();
                isScheduled = false; // 処理が終わったらフラグを下ろす
            });
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // 全体操作ボタンの配置
        injectGlobalControlButtons();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();

    // 初回実行
    // ページ読み込み完了時に実行
    window.addEventListener('load', applyCurtain);

    // 遅延読み込み等に対応する場合、少し待ってから実行(保険)
    setTimeout(applyCurtain, 1000);

})();