Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save heguro/73da2435cf2e9509a9d5fbda0d3b4dee to your computer and use it in GitHub Desktop.

Select an option

Save heguro/73da2435cf2e9509a9d5fbda0d3b4dee to your computer and use it in GitHub Desktop.
Mastodonで投稿に添付する画像にGeminiで説明を付ける機能を追加するUserScript (Tampermonkeyが入っている場合、右のRawボタン押下でインストール)
// ==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