// ==UserScript== // @name Adobe Stock AI Metadata Filler (Groq) // @namespace https://contributor.stock.adobe.com/ // @version 1.15.0 // @description 選択中の画像をGroq Vision APIに送信し、英語タイトル・キーワードを自動生成してフォームに入力する // @author you // @match https://contributor.stock.adobe.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect api.groq.com // @connect ftcdn.net // @connect *.ftcdn.net // ==/UserScript== (function () { 'use strict'; // ============================================================ // 設定エリア:Adobeのページ構造が変わったらここだけ直せばOK // ============================================================ const CONFIG = { // Groqで使うモデル(画像対応・JSONモード対応のもの) GROQ_MODEL: 'qwen/qwen3.8-27b', // 画像をGroqに送る前に、長辺がこのpx数になるようCanvasでリサイズする。 // トークン消費(≒コスト・レート制限への当たりやすさ)と、認識精度のトレードオフ。 // - 元々の実装は、Adobeの一覧表示用サムネイル(だいたい320x180)をそのまま送っていた。 // 元に戻したい場合は 0 を指定するとリサイズをスキップし、元画像をそのまま使う。 // (以前値の参考: 320 = Adobeのサムネイルそのまま相当の粗さ) // - 768 は「トークンを抑えつつ、細部もある程度残す」バランスを狙った値。 // 画質・認識精度を優先するなら 1024 などに上げてもよい。 IMAGE_MAX_DIMENSION: 768, // ---- 以下、Adobe側のDOM要素を指すセレクタ(devtoolsで確認済み・2026/07時点) ---- // 1) 現在パネルが開いている(選択中の)サムネイル SELECTED_THUMBNAIL_SELECTOR: '.upload-tile__wrapper.active img.upload-tile__thumbnail, [aria-selected="true"] img.upload-tile__thumbnail', // 2) 右側パネルのタイトル入力欄(classがビルドごとに変わる可能性があるため、 // name/aria-label/data-t属性で複数フォールバックを用意) TITLE_INPUT_SELECTOR: 'textarea[name="title"], textarea[aria-label="コンテンツタイトル"], textarea[data-t="asset-title-content-tagger"], textarea.input--full-title-input', // 3) キーワード入力欄(カンマ区切りのテキストをまるごと貼り付けるタイプのtextarea) KEYWORD_INPUT_SELECTOR: 'textarea#content-keywords-ui-textarea', }; // ============================================================ // ユーティリティ // ============================================================ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // Groqのエラーメッセージ内 "try again in 21m32.54s" のような表記を秒数に変換する // (TPM=1分あたりの上限では "18.1s" のように秒だけのことが多いが、 // TPD=1日あたりの上限では "21m32.54s" のように分が付くことがあるため、両方に対応する) function parseWaitSeconds(message) { const m = /try again in\s*((?:[\d.]+h)?(?:[\d.]+m)?(?:[\d.]+s)?)/i.exec(message); if (!m || !m[1]) return null; const dur = m[1]; const h = /([\d.]+)h/.exec(dur); const min = /([\d.]+)m/.exec(dur); const s = /([\d.]+)s/.exec(dur); if (!h && !min && !s) return null; let total = 0; if (h) total += parseFloat(h[1]) * 3600; if (min) total += parseFloat(min[1]) * 60; if (s) total += parseFloat(s[1]); return total; } function formatDuration(totalSec) { const sec = Math.ceil(totalSec); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const parts = []; if (h) parts.push(`${h}時間`); if (m) parts.push(`${m}分`); if (s || parts.length === 0) parts.push(`${s}秒`); return parts.join(''); } function getGroqKey() { return GM_getValue('GROQ_API_KEY', 'gsk_XXXXXXXXXXXXXXXXXXXXX'); } GM_registerMenuCommand('Groq APIキーを設定', () => { const current = getGroqKey(); const key = prompt('Groq API Keyを入力してください', current || ''); if (key !== null) { GM_setValue('GROQ_API_KEY', key.trim()); alert('保存しました'); } }); // React/Vue管理下のinput/textareaでも値変更を検知させるためのセッター function setNativeValue(element, value) { const proto = element.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; setter.call(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); } function simulateEnterKey(element) { ['keydown', 'keypress', 'keyup'].forEach((type) => { element.dispatchEvent( new KeyboardEvent(type, { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13, which: 13 }) ); }); } function querySelectorVisible(selector) { const all = document.querySelectorAll(selector); for (const el of all) { if (el.offsetParent !== null) return el; } return all[0] || null; } function getSelectedThumbnailUrl() { const img = document.querySelector(CONFIG.SELECTED_THUMBNAIL_SELECTOR); if (!img) return null; // あえてsrcsetの高解像度版は使わず、表示中の小さいサムネイル(src)をそのまま使う。 // 画像が大きいほどGroq側のトークン消費が増え、レート制限に当たりやすくなるため。 return img.src; } // Canvasで画像の長辺をmaxDim以下にリサイズし、JPEGのdata URLとして返す。 // 何らかの理由で失敗した場合は、リサイズを諦めて元のdata URLをそのまま返す(処理を止めないため)。 function resizeImageDataUrl(dataUrl, maxDim) { return new Promise((resolve) => { if (!maxDim || maxDim <= 0) { resolve(dataUrl); // 0以下ならリサイズなし(元の実装と同じ挙動) return; } const img = new Image(); img.onload = () => { try { const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); const targetW = Math.max(1, Math.round(img.width * scale)); const targetH = Math.max(1, Math.round(img.height * scale)); console.log( `[AIメタデータ生成] 画像サイズ: 元 ${img.width}x${img.height} → 送信 ${targetW}x${targetH}` + (scale < 1 ? '(縮小しました)' : '(元のサイズのまま・拡大はしていません)') ); const canvas = document.createElement('canvas'); canvas.width = targetW; canvas.height = targetH; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, targetW, targetH); resolve(canvas.toDataURL('image/jpeg', 0.85)); } catch (e) { resolve(dataUrl); // 描画やtoDataURLで失敗したら元画像にフォールバック } }; img.onerror = () => resolve(dataUrl); img.src = dataUrl; }); } function fetchImageAsBase64(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload: (res) => { const reader = new FileReader(); reader.onloadend = () => { // data:image/jpeg;base64,xxxx の形で返る resolve(reader.result); }; reader.onerror = () => reject(new Error('画像データの読み込みに失敗しました')); reader.readAsDataURL(res.response); }, onerror: (err) => { reject(new Error('画像の取得に失敗しました(@connectの許可ドメインを確認してください): ' + JSON.stringify(err))); }, }); }); } function callGroq(base64ImageDataUrl) { const apiKey = getGroqKey(); if (!apiKey) { alert('先にGroq APIキーを設定してください(Tampermonkeyメニューから)'); return Promise.reject(new Error('no api key')); } const CATEGORY_LIST = [ '動物', '建物・建築', 'ビジネス', '飲み物', '環境', '感情と情緒', '食べ物', 'グラフィック素材', '趣味とレジャー', '産業', '風景', 'ライフスタイル', '人物', '植物・花', '宗教・文化', '科学', '社会問題', 'スポーツ', 'テクノロジー', '交通', '旅行', ]; const systemPrompt = `あなたはAdobe Stockに投稿するAI生成画像のメタデータ作成を手伝うアシスタントです。 与えられた画像を見て、以下のJSONだけを出力してください(説明文やコードブロックの記号は不要)。 { "japanese_description": "画像の内容を日本語で1〜2文で説明(ユーザー確認用、投稿はしない)", "english_title": "Adobe Stock向けの英語タイトル。実在の人物やブランドを特定しない一般的な表現。1文(コンマやandでの軽い接続は可)。必ず120〜195文字の範囲に収めること(120文字未満は不可、200文字を超えるのは絶対不可)。被写体・服装・表情・構図・背景・雰囲気・光の質感・想定される用途(例: business, lifestyle, technology concept など)を具体的に描写し、詳しく書くことで文字数を満たすこと", "category_suggestions": ["カテゴリ名1", "カテゴリ名2"], "keywords": [ {"en": "英語キーワード(単語または短いフレーズ)", "ja": "対応する日本語訳(確認用、投稿はしない)"} ] } category_suggestionsについての注意: - 必ず次の一覧の中からこの通りの表記で1〜2個だけ選ぶこと(一覧にない名前を作らないこと): ${CATEGORY_LIST.join('、')} - 最も当てはまる順に並べること keywordsについての注意: - 関連度が高い順に並べること - 必ず40個以上、できれば45〜49個出すこと(Adobe Stockの上限は49個)。同義語・関連シーン・雰囲気・被写体属性・構図・色・場所なども含めて数を稼ぐこと。40個未満は不可 - 49個を超えないこと JSONの形式についての注意(重要): - 必ず有効なJSONのみを出力すること。前後に説明文やコードブロック(\`\`\`)を付けないこと - 文字列の中でダブルクォート(")を使わないこと(英語タイトル・キーワード・日本語訳のいずれにも、"で囲んだ強調表現などは使わない) - 配列やオブジェクトの末尾に余計なカンマ(トレイリングカンマ)を付けないこと`; const payload = { model: CONFIG.GROQ_MODEL, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: [ { type: 'text', text: 'この画像のメタデータをJSONで生成してください。' }, { type: 'image_url', image_url: { url: base64ImageDataUrl } }, ], }, ], temperature: 0.2, max_tokens: 4000, }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.groq.com/openai/v1/chat/completions', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, data: JSON.stringify(payload), onload: (res) => { try { const json = JSON.parse(res.responseText); if (json.error) { const err = new Error(json.error.message || 'Groq API error'); err.code = json.error.code || json.error.type; reject(err); return; } const content = json.choices[0].message.content; const parsed = JSON.parse(content); resolve(parsed); } catch (e) { reject(new Error('Groqの応答解析に失敗しました: ' + e.message + ' / raw: ' + res.responseText.slice(0, 300))); } }, onerror: (err) => { reject(new Error('Groq APIへの接続に失敗しました: ' + JSON.stringify(err))); }, }); }); } // ============================================================ // フォームへの反映 // ============================================================ // 200文字を超える場合、単語の途中で切らないよう「最後の単語の区切り」まで戻ってから切る function trimTitleSafely(title, maxLen) { if (title.length <= maxLen) return title; let cut = title.slice(0, maxLen); const lastSpace = cut.lastIndexOf(' '); if (lastSpace > maxLen * 0.6) { // 単語が短く切れすぎない位置に空白があれば、そこで切る cut = cut.slice(0, lastSpace); } // 末尾に中途半端な記号(カンマ・接続詞の残骸など)が残っていたら削る cut = cut.replace(/[,;:\-\s]+$/, ''); return cut; } function fillTitle(title) { const el = querySelectorVisible(CONFIG.TITLE_INPUT_SELECTOR); if (!el) { alert('タイトル欄が見つかりませんでした。CONFIG.TITLE_INPUT_SELECTORを調整してください'); return; } setNativeValue(el, title); } function fillKeywords(keywords) { const el = querySelectorVisible(CONFIG.KEYWORD_INPUT_SELECTOR); if (!el) { alert('キーワード欄が見つかりませんでした。CONFIG.KEYWORD_INPUT_SELECTORを調整してください'); return; } // このtextareaは「カンマ区切りのテキストをペースト」する仕様なので、 // 1語ずつEnterではなく、カンマ区切り文字列をまとめて流し込む const text = keywords.join(', '); setNativeValue(el, text); // Adobe側がpasteイベントを見ている可能性があるため、pasteイベントも念のため発火 try { const dt = new DataTransfer(); dt.setData('text/plain', text); el.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt })); } catch (e) { // DataTransfer/ClipboardEventが使えない環境では無視(input/changeイベントで反映されるはず) } } // ============================================================ // UIパネル // ============================================================ function injectPanel() { const panel = document.createElement('div'); panel.id = 'ai-meta-panel'; panel.innerHTML = `