Last active
May 25, 2025 02:53
-
-
Save heguro/73da2435cf2e9509a9d5fbda0d3b4dee to your computer and use it in GitHub Desktop.
Mastodonで投稿に添付する画像にGeminiで説明を付ける機能を追加するUserScript (Tampermonkeyが入っている場合、右のRawボタン押下でインストール)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Mastodon: 投稿に添付する画像にGeminiで説明を付ける機能を追加 | |
| // @namespace https://github.com/heguro | |
| // @version 0.1.20250525 | |
| // @description try to take over the world! | |
| // @author heguro/あすらも | |
| // @grant GM.xmlHttpRequest | |
| // @connect generativelanguage.googleapis.com | |
| // @match https://fedibird.com/* | |
| // @match https://mstdn.jp/* | |
| // ==/UserScript== | |
| // ↑の @match 部分に使いたいMastodonのドメインを増やしてください。 | |
| // Firefox+Tampermonkeyで動作確認済み | |
| (() => { | |
| // Google AI Studioなどで取得できるAPIキー | |
| // https://aistudio.google.com/apikey | |
| const GEMINI_API_KEY = "AIzaSyABCABC"; | |
| // モデル | |
| // https://ai.google.dev/gemini-api/docs/models | |
| const GEMINI_MODEL_ID = "gemini-2.5-flash-preview-05-20"; | |
| // 初期プロンプト | |
| const GEMINI_DEFAULT_PROMPT = `画像の内容から日本語のALTテキスト (<img alt=""> に使う) を作成して`; | |
| const GEMINI_DEFAULT_PROMPT_VIDEO = `動画全体の内容を、動画が見えない人のために説明する日本語のALTテキストを作成して`; | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| const blobToBase64 = (blob) => new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result.split(',')[1]); // Base64部分のみ取得 | |
| reader.readAsDataURL(blob); | |
| }); | |
| async function addAIButton() { | |
| let toolbar = document.getElementsByClassName("setting-text__toolbar")[0]; | |
| // 20回(適当)までリトライ | |
| for (let i = 0; i < 20; i++) { | |
| if (toolbar) break; | |
| await sleep(100); | |
| toolbar = document.getElementsByClassName("setting-text__toolbar")[0]; | |
| } | |
| if (!toolbar) return; | |
| const newToolbarDiv = document.createElement("div"); | |
| newToolbarDiv.className = "setting-text__toolbar"; | |
| const aiButton = document.createElement("button"); | |
| aiButton.className = "link-button ai-generate-description-button"; | |
| const buttonText = "Geminiで説明を生成"; | |
| aiButton.innerText = buttonText; | |
| aiButton.addEventListener("click", async (event) => { | |
| aiButton.disabled = true; | |
| try { | |
| aiButton.innerText = `${buttonText}...`; | |
| await runAI(); | |
| } finally { | |
| aiButton.innerText = buttonText; | |
| aiButton.disabled = false; | |
| } | |
| }); | |
| newToolbarDiv.append(aiButton); | |
| toolbar.insertAdjacentElement("afterend", newToolbarDiv); | |
| } | |
| async function runAI() { | |
| const imageElement = document.querySelector(".focal-point-modal__content video, .focal-point-modal__content img"); | |
| if (!imageElement) return; | |
| const imageURL = imageElement.src; | |
| const defaultPrompt = imageElement.tagName === "IMG" ? GEMINI_DEFAULT_PROMPT : GEMINI_DEFAULT_PROMPT_VIDEO; | |
| const editedPrompt = prompt("プロンプトを入力 (空白でキャンセル)", defaultPrompt); | |
| if (!editedPrompt) return; | |
| const imageBlob = await fetch(imageURL).then(response => response.blob()); | |
| const base64ImageData = await blobToBase64(imageBlob); | |
| // Geminiのパラメータ | |
| const geminiReq = { | |
| contents: [{ | |
| parts: [ | |
| { | |
| inlineData: { | |
| mimeType: imageBlob.type, | |
| data: base64ImageData, | |
| }, | |
| }, | |
| { text: editedPrompt }, | |
| ] | |
| }], | |
| generationConfig: { | |
| // Gemini 2.5 Flash 用設定 | |
| // https://ai.google.dev/gemini-api/docs/thinking#set-budget | |
| thinkingConfig: { | |
| thinkingBudget: 0, | |
| }, | |
| responseMimeType: "application/json", | |
| responseSchema: { | |
| type: "object", | |
| properties: { | |
| alt_text: { type: "string" }, | |
| }, | |
| required: [ "alt_text"], | |
| }, | |
| }, | |
| }; | |
| const geminiReqUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL_ID}:generateContent?key=${GEMINI_API_KEY}`; | |
| // Mastodon側で他のドメインとやりとりできないようCSPが設定されている場合fetch()で取れないので | |
| // GreaseMonkey/TamperMonkeyの機能でリクエスト | |
| const geminiRes = await GM.xmlHttpRequest({ | |
| method: "POST", | |
| url: geminiReqUrl, | |
| headers: { "Content-Type": "application/json" }, | |
| data: JSON.stringify(geminiReq), | |
| responseType: "json", | |
| }).then(response => response.response); | |
| console.log("response by gemini:", geminiRes); | |
| const resContent = JSON.parse(geminiRes?.candidates[0]?.content?.parts[0]?.text ?? "{}"); | |
| const altText = resContent?.alt_text; | |
| if (!altText) return; | |
| const aiButton = document.getElementsByClassName("ai-generate-description-button")[0]; | |
| const textarea = document.getElementById("upload-modal__description"); | |
| if (!textarea) return; | |
| textarea.value = altText; | |
| textarea.focus(); | |
| // textarea.value にセットするだけだと、Reactの内部変数に反映されずテキストを編集したことにならない | |
| // ユーザーに編集させる必要がある (多分) | |
| aiButton.innerText = "Enterなどのキーを押してください (技術的制約のため)"; | |
| // input されるまで待つ | |
| await new Promise((resolve) => { | |
| const onInput = () => { | |
| textarea.removeEventListener("input", onInput); | |
| resolve(); | |
| }; | |
| textarea.addEventListener("input", onInput); | |
| }); | |
| } | |
| function composeFormClicked(event) { | |
| // 画像編集ボタンが押された場合のみ addAIButton する | |
| let target = event.target; | |
| let parent = target; | |
| let isEditButton = false; | |
| while (parent) { | |
| if (parent instanceof HTMLButtonElement && parent.firstElementChild?.classList.contains("fa-pencil")) { | |
| isEditButton = true; | |
| break; | |
| } | |
| parent = parent.parentElement; | |
| } | |
| if (isEditButton) { | |
| addAIButton(); | |
| } | |
| } | |
| function addEditButtonClickListener() { | |
| const composeForm = document.getElementsByClassName("compose-form")[0]; | |
| composeForm?.addEventListener("click", composeFormClicked); | |
| }; | |
| window.addEventListener("load", addEditButtonClickListener); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment