AIMG Auto Image Metadata Scanner

画像リンクのメタデータを解析し、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能を提供します

目前為 2025-11-28 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AIMG Auto Image Metadata Scanner
// @name:ja      AIMG 自動メタデータスキャナー
// @namespace    https://nijiurachan.net/pc/catalog.php
// @version      1.1
// @description  画像リンクのメタデータを解析し、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能を提供します
// @author       doridoridoridorin
// @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
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 1. 設定・定数定義
    // ==========================================

    // NovelAIのステルス画像に含まれる固有のマジックナンバー(識別子)
    const NOVELAI_MAGIC = "stealth_pngcomp";

    // 解析対象とする画像の拡張子(リンクURLの末尾で判定)
    // PNG, WebP, JPEG (jpg/jpeg) を対象とする
    const TARGET_EXTENSIONS = /\.(png|webp|jpe?g)$/i;

    // 同時に実行するHTTPリクエストの最大数
    // サーバーへの負荷軽減と、ブラウザのネットワーク詰まりを防ぐために制限
    const MAX_CONCURRENT_REQUESTS = 3;

    // ==========================================
    // 2. リクエストキュー管理システム
    // ==========================================
    // 「一括解析」などで大量のリクエストが発生した際、
    // 一気に通信せず順番に処理するための仕組み

    const requestQueue = []; // 実行待ちのタスクを格納する配列
    let activeRequests = 0;  // 現在実行中のリクエスト数

    /**
     * キューからタスクを取り出して実行する関数
     * 再帰的に呼び出され、空き枠がある限り処理を続ける
     */
    function processQueue() {
        // 同時接続数が上限に達しているか、キューが空なら何もしない
        if (activeRequests >= MAX_CONCURRENT_REQUESTS || requestQueue.length === 0) return;

        const task = requestQueue.shift(); // 先頭のタスクを取得
        activeRequests++;
        task.onStart(); // UIを「解析中」表示に更新

        // GM_xmlhttpRequestを利用して画像データをバイナリ(ArrayBuffer)として取得
        // 通常のfetchではCORS制限で他サイトの画像データをCanvasで扱えないため、GM関数が必須
        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(); // エラーでも止まらず次へ
            }
        });
    }

    /**
     * 新しい解析タスクをキューに追加する
     * @param {string} url - 画像URL
     * @param {object} callbacks - UI更新用のコールバック関数群 (onStart, onSuccess, onError)
     */
    function addToQueue(url, callbacks) {
        requestQueue.push({ url, ...callbacks });
        processQueue(); // 処理開始を試みる
    }

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

    // --- LSBExtractor クラス ---
    // 画像のピクセルデータ(RGBA)から最下位ビット(LSB)を抽出するクラス
    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)」でデータを埋め込むため、
         * [x=0, y=0] -> [x=0, y=1] ... と縦方向に走査する
         */
        getNextBit() {
            if (this.col >= this.width) return null;
            // RGBAなのでインデックスは4倍。Alphaチャンネル(+3)の最下位ビット(&1)を取得
            const pixelIndex = (this.row * this.width + this.col) * 4;
            const bit = this.pixels[pixelIndex + 3] & 1;

            // 次の座標へ移動(縦方向へ)
            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);
        }
    }

    // --- 画像ユーティリティ ---

    // PNGとWebPのファイルヘッダー(シグネチャ)定義
    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経由でピクセルデータ(RGBA)を取得する非同期関数
    async function getPixelsFromBlob(blob) {
        return new Promise((resolve, reject) => {
            const url = URL.createObjectURL(blob);
            const img = new Image();
            // Canvas汚染回避(GM_xmlhttpRequest経由なら基本安全だが念のため)
            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;
        });
    }

    // --- メイン解析関数 ---
    async function analyzeImage(arrayBuffer) {
        const results = [];
        const uint8Data = new Uint8Array(arrayBuffer);

        // ファイルタイプの判定(LSB解析を行うかどうかの判断に使用)
        let mimeType = '';
        if (checkSignature(uint8Data, PNG_SIGNATURE)) mimeType = 'image/png';
        else if (checkSignature(uint8Data, WEBP_SIGNATURE)) mimeType = 'image/webp';

        // Blob作成(WebPはtype指定がないと正しく読み込めない場合がある)
        const blob = mimeType ? new Blob([uint8Data], { type: mimeType }) : new Blob([uint8Data]);

        // 1. 一般メタデータ解析 (ExifReader使用)
        try {
            // @ts-ignore
            const tags = window.ExifReader.load(arrayBuffer);
            const generalData = {};

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

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

            // NovelAI (WebP UserComment)
            if (tags.UserComment?.description) {
                // ヘッダーゴミ除去 (UNICODE nullなど)
                let cleanText = tags.UserComment.description.replace(/^UNICODE\x00/, '').replace(/^\x00+/, '');
                try {
                    // NovelAIの署名チェック
                    if (cleanText.includes('"signed_hash":')) {
                        const s = cleanText.indexOf('{');
                        const e = cleanText.lastIndexOf('}');
                        if (s !== -1 && e !== -1) {
                            generalData['NovelAI (WebP)'] = JSON.parse(cleanText.substring(s, e + 1));
                        }
                    } else { generalData['UserComment'] = cleanText; }
                } catch (e) { generalData['UserComment'] = cleanText; }
            }

            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解析 (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) {
                    // データ長読み取り -> データ抽出 -> gzip解凍 -> JSONパース
                    const dataLength = extractor.readUint32BE() / 8;
                    // @ts-ignore
                    const decompressedData = window.pako.inflate(extractor.getNextNBytes(dataLength));
                    results.push({ type: 'NovelAI Stealth', content: JSON.parse(new TextDecoder().decode(decompressedData)) });
                }
            } catch (e) { /* LSBなし */ }
        }
        return results;
    }

    // --- プロンプト抽出ロジック ---
    function extractPrompts(results) {
        let prompt = ""; let uc = ""; let charPrompt = []; let charUc = []; let found = false;

        // 優先順位: LSB > 標準メタデータ
        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;
            // Commentが文字列の場合はJSONパースを試みる
            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形式の対応 (captionオブジェクト)
            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 };
        };

        if (lsbData && lsbData.content) {
            const extracted = parseNaiJson(lsbData.content);
            prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true;
        } 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;
            } 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;
            } else if (content['Stable Diffusion']) {
                // A1111形式のテキスト解析
                const raw = content['Stable Diffusion'];
                const negSplit = raw.split(/Negative prompt:/i);
                prompt = negSplit[0].trim();
                if (negSplit[1]) uc = negSplit[1].split(/Steps:/i)[0].trim();
                found = true;
            }
        }
        return { prompt, uc, found, charPrompt, charUc };
    }

    // ==========================================
    // 4. 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 } = 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', true);
        const tabRaw = createTab('📄 Raw Data', false);
        tabHeader.appendChild(tabPrompt); tabHeader.appendChild(tabRaw); container.appendChild(tabHeader);

        // --- Prompt View ---
        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(); GM_setClipboard(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));
            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 View ---
        const viewRaw = document.createElement('div'); viewRaw.style.display = 'none';
        results.forEach(res => {
            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;
    }

    // ==========================================
    // 5. メイン UI / ロジック (アタッチメント)
    // ==========================================

    function attachScanner(anchor) {
        if (anchor.dataset.metaScannerAttached) return; // 二重適用防止

        const href = anchor.href;
        const childImg = anchor.querySelector('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';

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

        let detailBox = null;
        let isAnalyzing = false;

        // 解析開始処理(クリック・ホバー・一括解析で共有される関数)
        const startAnalysis = () => {
            // 既に解析中や完了済みの場合はスキップ
            if (isAnalyzing || btn.dataset.status !== 'unanalyzed') return;

            isAnalyzing = true;
            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';

                        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 = '✅ メタデータ';
                            }
                        };
                        // 解析完了後はホバーイベントを解除(無駄な発火を防ぐ)
                        anchor.removeEventListener('mouseenter', startAnalysis);

                    } else {
                        // データなし:透明/灰色ボタン
                        btn.textContent = '❌ 取得できませんでした';
                        btn.style.backgroundColor = 'transparent';
                        btn.style.color = '#999';
                        btn.style.opacity = '0.5';
                        btn.style.cursor = 'default';
                        // ホバー解除
                        anchor.removeEventListener('mouseenter', startAnalysis);
                    }
                },
                onError: (err) => {
                    console.error(err);
                    btn.textContent = '⚠️ エラー';
                    btn.style.backgroundColor = '#FFCDD2';
                    btn.style.color = '#D32F2F';
                    btn.dataset.status = 'error';
                }
            });
        };

        // イベント設定
        // 1. ボタンクリックで解析開始
        btn.onclick = (e) => {
            e.preventDefault(); e.stopPropagation();
            startAnalysis();
        };

        // 2. リンクホバー(マウスエンター)で解析開始
        anchor.addEventListener('mouseenter', startAnalysis);

        // 関数をボタン要素にプロパティとして保持(一括解析から呼び出すため)
        btn.startMetaAnalysis = startAnalysis;
    }

    // ==========================================
    // 6. 一括解析ボタン
    // ==========================================
    function injectGlobalControlButtons() {
        // ボタン生成ヘルパー
        const createGlobalButton = () => {
            const btn = document.createElement('button');
            btn.textContent = '🚀 全画像を解析';
            Object.assign(btn.style, {
                fontSize: '12px', padding: '6px 12px', margin: '10px 0', border: 'none', borderRadius: '4px',
                backgroundColor: '#2196F3', color: 'white', cursor: 'pointer', fontWeight: 'bold', boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
            });

            btn.onclick = () => {
                // ページ内の未解析ボタンを全て取得
                const unanalyzedBtns = document.querySelectorAll('button.meta-scan-btn[data-status="unanalyzed"]');
                if (unanalyzedBtns.length === 0) {
                    alert('未解析の画像はありません');
                    return;
                }
                if (!confirm(`${unanalyzedBtns.length}枚の画像を解析しますか?`)) return;

                // すべての未解析ボタンの解析関数を実行(キューに入るので順番に処理される)
                unanalyzedBtns.forEach(b => {
                    if (typeof b.startMetaAnalysis === 'function') {
                        b.startMetaAnalysis();
                    }
                });
            };
            return btn;
        };

        // ページ上部のナビゲーション下に追加
        const topNav = document.querySelector('.thread-nav.thread-nav-top');
        if (topNav) {
            const btnTop = createGlobalButton();
            const wrapper = document.createElement('div');
            wrapper.style.textAlign = 'center';
            wrapper.appendChild(btnTop);
            topNav.insertAdjacentElement('afterend', wrapper);
        }

        // ページ下部のナビゲーション上に追加
        const bottomNav = document.querySelector('.thread-nav.thread-nav-bottom');
        if (bottomNav) {
            const btnBottom = createGlobalButton();
            const wrapper = document.createElement('div');
            wrapper.style.textAlign = 'center';
            wrapper.appendChild(btnBottom);
            bottomNav.insertAdjacentElement('beforebegin', wrapper);
        }
    }

    // ==========================================
    // 7. 初期化処理
    // ==========================================
    function init() {
        // ページ内の全リンクをスキャン
        const anchors = document.querySelectorAll('a');
        anchors.forEach(attachScanner);

        // 動的に追加される要素(無限スクロール等)を監視
        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);
                    }
                });
            });
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // 一括解析ボタンの配置
        injectGlobalControlButtons();
    }

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

})();