画像リンクのメタデータを解析し、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能を提供します
目前為
// ==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(); })();