AIMG Auto Image Metadata Scanner

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

当前为 2025-11-28 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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.0
// @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';

    // ==========================================
    // 設定・定数定義
    // ==========================================

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

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

    // 同時に実行するHTTPリクエストの最大数
    // ブラウザの負荷軽減とサーバーへの配慮のため制限を設ける
    const MAX_CONCURRENT_REQUESTS = 3;

    // ==========================================
    // リクエストキュー管理システム
    // ==========================================
    // ページ内に大量の画像リンクがある場合、一斉に通信すると詰まるため
    // 順番に処理を行うための仕組み

    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を使用してクロスオリジン制限を回避してバイナリデータを取得
        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更新用のコールバック関数群
     */
    function addToQueue(url, callbacks) {
        requestQueue.push({ url, ...callbacks });
        processQueue();
    }

    // ==========================================
    // LSB (Least Significant Bit) 解析クラス
    // ==========================================
    // 画像のピクセルデータ(RGBA)のアルファチャンネルの最下位ビットに
    // 隠されたデータを抽出するためのクラス
    class LSBExtractor {
        /**
         * @param {Uint8ClampedArray} pixels - Canvasから取得したピクセルデータ
         * @param {number} width - 画像の幅
         * @param {number} height - 画像の高さ
         */
        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; // 画像範囲外

            // 現在のピクセルのインデックス計算 (RGBAなので4倍)
            const pixelIndex = (this.row * this.width + this.col) * 4;

            // Alphaチャンネル(index+3)の値を取得し、最下位ビット(& 1)を取り出す
            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;
        }

        /**
         * 指定したバイト数だけ読み込んでUint8Arrayで返す
         */
        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;
        }

        /**
         * 4バイト読み込んで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" (WebPは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に描画してピクセルデータを取得する
     * ブラウザの画像デコード機能を利用するため、Blob経由でImageオブジェクトを作る
     */
    async function getPixelsFromBlob(blob) {
        return new Promise((resolve, reject) => {
            const url = URL.createObjectURL(blob);
            const img = new Image();

            // Canvas汚染(Tainted Canvas)を防ぐために匿名モードを設定
            // ※GM_xmlhttpRequest経由のデータはBlob化されるため基本安全だが念のため
            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 {
                    // ピクセルデータを取得 (RGBA配列)
                    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);

        // 1. ファイルタイプ判定
        // WebPの場合、Blob作成時にMIMEタイプを指定しないとImage.srcで読み込めない場合があるため判定
        let mimeType = '';
        if (checkSignature(uint8Data, PNG_SIGNATURE)) {
            mimeType = 'image/png';
        } else if (checkSignature(uint8Data, WEBP_SIGNATURE)) {
            mimeType = 'image/webp';
        }

        // Blobを作成 (MIMEタイプがあれば指定)
        const blob = mimeType ? new Blob([uint8Data], { type: mimeType }) : new Blob([uint8Data]);

        // 2. 一般的なメタデータ解析 (ExifReader ライブラリ使用)
        // ここではA1111形式のPNGInfoや、標準的なExif/WebPメタデータを取得
        try {
            // @ts-ignore (ライブラリの型定義回避)
            const tags = window.ExifReader.load(arrayBuffer);
            const generalData = {};

            // Stable Diffusion (Automatic1111) 形式のパラメータ
            if (tags.parameters?.description) {
                generalData['Stable Diffusion'] = tags.parameters.description;
            }

            // NovelAI (V3以前など) のExif Comment
            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) {
                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) {
                            // JSON部分だけ切り出してパース
                            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) { /* 解析エラーは無視して次へ */ }

        // 3. LSB解析 (NovelAI Stealth)
        // PNGまたはWebPと判定された場合のみ実行
        if (mimeType) {
            try {
                // 画像をCanvasに描画してピクセルデータを取得
                const { pixels, width, height } = await getPixelsFromBlob(blob);
                const extractor = new LSBExtractor(pixels, width, height);

                // マジックナンバーをチェックしてステルス情報の有無を確認
                const magicBytes = extractor.getNextNBytes(NOVELAI_MAGIC.length);
                const magicString = new TextDecoder().decode(magicBytes);

                if (magicString === NOVELAI_MAGIC) {
                    // データ長を取得
                    const dataLength = extractor.readUint32BE() / 8;
                    // 圧縮されたデータを取得
                    const compressedData = extractor.getNextNBytes(dataLength);
                    // gzip解凍 (pakoライブラリ使用)
                    // @ts-ignore
                    const decompressedData = window.pako.inflate(compressedData);
                    // 文字列化してJSONパース
                    const jsonString = new TextDecoder().decode(decompressedData);
                    results.push({ type: 'NovelAI Stealth', content: JSON.parse(jsonString) });
                }
            } catch (e) {
                // LSBデータがない、または破損している場合はここで終了
            }
        }

        return results;
    }

    // ==========================================
    // プロンプト情報抽出・整形ロジック
    // ==========================================

    /**
     * 解析結果からプロンプト、ネガティブプロンプト、キャラクタープロンプトを抽出する
     * 優先順位: LSB > Exif > WebP > Stable Diffusion
     */
    function extractPrompts(results) {
        let prompt = "";
        let uc = "";
        let charPrompt = [];
        let charUc = [];
        let found = false;

        // 各種ソースからのデータを検索
        const lsbData = results.find(r => r.type === 'NovelAI Stealth');
        const standardData = results.find(r => r.type === 'Standard Metadata');

        /**
         * NovelAI形式のJSONからプロンプトを抜き出すヘルパー関数
         * V3形式とV4形式(captionオブジェクト)の両方に対応
         */
        const parseNaiJson = (json) => {
            let content = json;

            // Exifなどの場合、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.base_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) {
            // LSBデータが最優先
            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]) {
                    const stepSplit = negSplit[1].split(/Steps:/i);
                    uc = stepSplit[0].trim();
                }
                found = true;
            }
        }
        return { prompt, uc, found, charPrompt, charUc };
    }

    // ==========================================
    // 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);

        // --- View 1: Prompt & UC 表示エリア ---
        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);

        // --- View 2: Raw Data 表示エリア ---
        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';

            if (typeof res.content === 'object') {
                contentPre.textContent = JSON.stringify(res.content, null, 2);
            } else {
                contentPre.textContent = 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;
    }

    // ==========================================
    // UI アタッチメント(リンクへの機能追加)
    // ==========================================

    function attachScanner(anchor) {
        if (anchor.dataset.metaScannerAttached) return; // 既に処理済みならスキップ

        const href = anchor.href;
        const childImg = anchor.querySelector('img');

        // 条件: hrefが画像ファイルであり、かつ画像タグを含んでいる
        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: '#ccc', color: '#333', cursor: 'wait'
        });
        btn.disabled = true;

        uiContainer.appendChild(btn);

        // リンクの直後にUIを挿入
        if (anchor.nextSibling) anchor.parentNode.insertBefore(uiContainer, anchor.nextSibling);
        else anchor.parentNode.appendChild(uiContainer);

        let detailBox = null;

        // 解析キューに追加
        addToQueue(href, {
            onStart: () => {
                btn.textContent = '🔄 解析中...';
                btn.style.backgroundColor = '#FFEB3B';
            },
            onSuccess: (results) => {
                if (results.length > 0) {
                    // 解析成功・データあり
                    btn.textContent = '✅ メタデータ';
                    btn.style.backgroundColor = '#4CAF50';
                    btn.style.color = 'white';
                    btn.style.cursor = 'pointer';
                    btn.disabled = false;

                    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';
                }
            },
            onError: (err) => {
                console.error(err);
                btn.textContent = '⚠️ エラー';
                btn.style.backgroundColor = '#FFCDD2';
                btn.style.color = '#D32F2F';
            }
        });
    }

    // ==========================================
    // 初期化・監視処理
    // ==========================================

    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 });
    }

    // ページの読み込み完了を待って開始
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();

})();