Universal ALT Text Viewer

Display and copy ALT text (alternative text) for images, GIFs, and videos on Twitter, Bluesky, and Tokimeki.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name           Universal ALT Text Viewer
// @icon           data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛄</text></svg>
// @description    Display and copy ALT text (alternative text) for images, GIFs, and videos on Twitter, Bluesky, and Tokimeki.
// @description:ja Twitter, Bluesky, Tokimekiの画像、GIF、動画のALTテキスト(代替テキスト)を表示・コピーします。
// @namespace      https://bsky.app/profile/neon-ai.art
// @homepage       https://neon-aiart.github.io/
// @version        3.0
// @author         ねおん
// @match          https://twitter.com/*
// @match          https://x.com/*
// @match          https://bsky.app/*
// @match          https://tokimeki.blue/*
// @grant          GM_addStyle
// @run-at         document-idle
// @license        PolyForm Noncommercial 1.0.0; https://polyformproject.org/licenses/noncommercial/1.0.0/
// ==/UserScript==

/**
 * ==============================================================================
 * IMPORTANT NOTICE / 重要事項
 * ==============================================================================
 * Copyright (c) 2025-2026 ねおん (Neon)
 * Licensed under the PolyForm Noncommercial License 1.0.0.
 * * [JP] 本スクリプトは個人利用・非営利目的でのみ使用・改変が許可されます。
 * 無断転載、作者名の書き換え、およびクレジットの削除は固く禁じます。
 * 本スクリプトを改変・配布(フォーク)する場合は、必ず元の作者名(ねおん)
 * およびこのクレジット表記を維持してください。
 * * [EN] This script is licensed for personal and non-commercial use only.
 * Unauthorized re-uploading, modification of authorship, or removal of
 * author credits is strictly prohibited. If you fork this project, you MUST
 * retain the original credits and authorship.
 * ==============================================================================
 */

(function() {
    'use strict';

    const SCRIPT_VERSION = '3.0';

    // 除外ALTテキスト
    const localizedImageStrings = [
        "画像", "Image", "圖片", "이미지", "Imagen", "Bild",
        "Immagine", "Imagem", "Foto", "Rasm", "Kép", "zdjęcie",
        "埋め込み動画", "埋め込みビデオプレーヤー",
    ];

    // NGワード
    const TEXTS_TO_REMOVE_REGEX = [
        /^Alt:\s*/i, // 先頭の "Alt: "
        /^\/\/Character\n1girl,\nBREAK\n\/\/Fashions\n/i, // 先頭のプロンプト定型文
    ];

    /*
     * --- プラットフォーム設定の解説 ---
     * root: 監視の起点となる要素(ポスト全体など)。ここに追加があったら中身をスキャンする。
     * targets: 対象となる画像/動画の設定リスト。
     *   - containerSelector: [必須] ALTボタンを設置する親要素(コンテナ)。
     *                        :has() や > を使って「ALTを持つ要素の親」を特定する。
     *                        ボタンはここに appendChild される。
     *   - textSelector:      [任意] コンテナ内部で実際に代替テキストを持っている要素。
     *                        省略時は containerSelector 自身から属性を探す。
     *   - attr:              [必須] 取得する属性名 ('alt', 'aria-label', 'innerText'など)。
     *                        'innerText' を指定すると属性ではなくタグの中身を取得する。
     *   - position:          ボタンの表示位置
     */
    const platformConfigs = [
        {
            name: 'Twitter/X',
            hostnames: ['twitter.com', 'x.com',],
            root: 'article[data-testid="tweet"]',
            targets: [
                {
                    containerSelector: 'div[data-testid="tweetPhoto"][aria-label]',
                    textSelector: '', // コンテナ自身を対象にするので空文字にする
                    attr: 'aria-label',
                    position: 'top: 10px; left: 12px;',
                },
                {
                    containerSelector: 'div[data-testid="tweetPhoto"]:has(video[aria-label])',
                    textSelector: 'video[aria-label]',
                    attr: 'aria-label',
                    position: 'top: 10px; left: 12px;',
                },
            ],
        },
        {
            name: 'Bluesky',
            hostnames: ['bsky.app',],
            root: 'div[data-testid*="-by-"], div[role="link"]:has(div[data-testid="userAvatarImage"])',
            targets: [
                {
                    // 画像: [data-expoimage] を利用し、アバターを弾く
                    containerSelector: 'div[data-expoimage]:has(img[alt])',
                    textSelector: 'img[alt]',
                    attr: 'alt',
                    position: 'bottom: 10px; left: 10px;',
                },
                {
                    // GIFステッカー
                    containerSelector: 'div:has(> video[aria-label])',
                    textSelector: 'video[aria-label]',
                    attr: 'aria-label',
                    position: 'bottom: 10px; left: 10px;',
                },
                {
                    // GIF・動画
                    containerSelector: 'div[aria-label]:has(video):has(figcaption)',
                    textSelector: 'figcaption',
                    attr: 'innerText',
                    position: 'bottom: 60px; left: 10px;',
                },
            ],
        },
        {
            name: 'Tokimeki',
            hostnames: ['tokimeki.blue',],
            // root: 各表示エリアの外枠
            root: 'article.timeline__item, article.notifications-item, dialog.media-content-wrap',
            targets: [
                {
                    // タイムライン・通知
                    containerSelector: 'div.timeline-image:not(.avatar div):has(img[alt])',
                    textSelector: 'img[alt]',
                    attr: 'alt',
                    position: 'bottom: 10px; left: 10px;',
                },
                {
                    // メディア詳細モーダル
                    containerSelector: 'div.media-content__image:has(img[alt])',
                    textSelector: 'img[alt]',
                    attr: 'alt',
                    position: 'bottom: 10px; left: 10px;',
                },
                {
                    // GIFステッカー
                    containerSelector: 'div.timeline-external--tenor:has(video.gif-video)',
                    textSelector: 'p.timeline-external__description',
                    attr: 'innerText',
                    position: 'top: 10px; left: 10px;',
                },
                {
                    // GIF・動画
                    containerSelector: 'div.timeline-video-wrap:has(video), div.timeline-video-wrap:has(.video-player)',
                    textSelector: '',
                    attr: 'alt',
                    position: 'bottom: 60px; right: 10px;',
                },
            ],
        },
    ];

    const currentPlatform = platformConfigs.find(p => p.hostnames.some(h => window.location.hostname.includes(h)));

    if (!currentPlatform) {
        return; // 対象外のURL
    }

    // --- スタイル ---
    GM_addStyle(`
        @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200');

        /* コピーボタンのスタイル */
        .alt-button {
            position: absolute;
            z-index: 99;
            cursor: pointer;
            background-color: rgba(29, 155, 240, 0.9);
            color: white;
            width: 30px;
            height: 30px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            opacity: 0;
            transition: opacity 0.2s ease;
            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
        }

        /* 親要素に relative を強制するためのクラス(ボタンの絶対配置基準用) */
        .alt-container-relative {
            position: relative !important;
        }

        .alt-button .material-symbols-outlined {
            font-size: 18px;
            font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
            pointer-events: none; /* アイコン自体はクリックを透過 */
        }

        /* ツールチップ */
        .alt-tooltip {
            position: absolute;
            z-index: 10000;
            background-color: rgba(30, 30, 30, 0.95);
            top: 0;
            left: 0;
            color: white;
            padding: 8px 12px;
            border-radius: 8px;
            max-width: min(80%, 320px);
            max-height: min(72%, 480px);
            font-size: 14px;
            line-height: 1.4;
            visibility: hidden;
            opacity: 0;
            transition: opacity 0.2s ease;
            white-space: pre-wrap;
            overflow-wrap: break-word;
            overflow: auto;
            word-break: break-word;
            box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            user-select: text; /* テキスト選択可能に */
            cursor: auto;
            pointer-events: auto;
        }
        /* ボタンホバー時、その親にあるリンクの反応を消す */
        .alt-button:hover {
            box-shadow: 0 0 0 1000px rgba(0,0,0,0); /* 当たり判定を広げる裏技 */
        }
    `);

    // --- メイン処理 ---

    // 代替テキストの有無・妥当性を判定
    function isValidAltText(text) {
        if (!text) {
            return false;
        }
        const trimmed = text.trim();
        return trimmed.length > 12 && !localizedImageStrings.includes(trimmed);
    }

    /**
     * 指定された要素からテキストを取得
     * @param {HTMLElement} element - 対象要素
     * @param {string} attr - 属性名 ('alt', 'aria-label', 'innerText')
     */
    function getAltText(element, attr) {
        if (!element) {
            return null;
        }
        let text = (attr === 'innerText')
            ? element.innerText
            : (element.getAttribute(attr) || '');

        // --- NGワード(正規表現)に一致する部分をすべて消去 ---
        TEXTS_TO_REMOVE_REGEX.forEach(regex => {
            text = text.replace(regex, '');
        });
        return text.trim();
    }

    // コピーボタンを生成・設置
    function createAltButton(container, text, position) {
        if (!container || container.querySelector('.alt-button')) {
            return;
        }

        // モーダル(dialog)を判定
        const isInsideModal = container.closest('dialog');
        const appendTarget = isInsideModal ? container : document.body;

        // コンテナのスタイル調整(absolute配置の基準にするためrelativeにを付与)
        const style = window.getComputedStyle(container);
        if (style.position === 'static') {
            container.classList.add('alt-container-relative');
        }

        // コピーボタン作成
        const btn = document.createElement('div');
        btn.className = 'alt-button';
        btn.innerHTML = '<span class="material-symbols-outlined">content_copy</span>';
        btn.style.cssText += position;

        // 正規表現で position から数値部分を抽出して clamp をかける
        const applyClamp = (prop) => {
            const match = position.match(new RegExp(`${prop}:\\s*([^;]+)`));
            if (match) {
                // 5pxから、(100% - ボタン幅35px)の間に収める
                btn.style[prop] = `clamp(5px, ${match[1]}, calc(100% - 35px))`;
            }
        };
        ['top', 'bottom', 'left', 'right',].forEach(applyClamp);

        // ツールチップ作成
        const tip = document.createElement('div');
        tip.className = 'alt-tooltip';
        tip.textContent = text;

        // 判定に基づいたターゲットにアペンド
        appendTarget.appendChild(tip);

        let isHovering = { btn: false, tip: false, container: false, };

        const update = () => {
            // コンテナにマウスがあるならボタン表示
            btn.style.opacity = (isHovering.btn || isHovering.tip || isHovering.container) ? '1' : '0';

            // ボタンかチップにマウスがあるならチップ表示
            if (isHovering.btn || isHovering.tip) {
                tip.style.visibility = 'visible';
                tip.style.opacity = '1';

                // --- 座標計算のコア ---
                if (isInsideModal) {
                    // コンテナ内(v2.7方式): offsetを使う
                    const isTopArea = btn.offsetTop < (container.offsetHeight / 2);
                    tip.style.top = isTopArea
                        ? (btn.offsetTop + btn.offsetHeight + 2) + 'px'
                        : (btn.offsetTop - tip.offsetHeight - 2) + 'px';

                    if (position.includes('left')) {
                        tip.style.left = btn.offsetLeft + 'px';
                    } else {
                        tip.style.left = (btn.offsetLeft + btn.offsetWidth - tip.offsetWidth) + 'px';
                    }
                } else {
                    // Body内(v2.5拡張方式): getBoundingClientRectを使う
                    const rect = btn.getBoundingClientRect();
                    const scrollY = window.scrollY;
                    const scrollX = window.scrollX;

                    // ボタンが画面の上半分にあるか判定
                    const isTopViewport = rect.top < (window.innerHeight / 2);

                    tip.style.top = isTopViewport
                        ? (rect.bottom + scrollY + 2) + 'px' // 下に出す
                        : (rect.top + scrollY - tip.offsetHeight - 2) + 'px'; // 上に出す

                    let leftPos = rect.right + scrollX - tip.offsetWidth;
                    tip.style.left = (leftPos < 10 ? 10 : leftPos) + 'px';
                }
            } else {
                setTimeout(() => {
                    if (!isHovering.btn && !isHovering.tip) {
                        tip.style.visibility = 'hidden';
                        tip.style.opacity = '0';
                    }
                }, 200); // 少し遅れて消す(マウス移動用)
            }
        };

        // イベントリスナー
        container.addEventListener('mouseenter', () => {
            isHovering.container = true; update();
        });
        container.addEventListener('mouseleave', () => {
            isHovering.container = false; update();
        });
        btn.addEventListener('mouseenter', () => {
            isHovering.btn = true; update();
        });
        btn.addEventListener('mouseleave', () => {
            isHovering.btn = false; update();
        });
        tip.addEventListener('mouseenter', () => {
            isHovering.tip = true; update();
        });
        tip.addEventListener('mouseleave', () => {
            isHovering.tip = false; update();
        });

        // コピー機能
        btn.addEventListener('click', (e) => {
            e.preventDefault();           // 1. デフォルトの挙動(リンク移動)を阻止
            e.stopPropagation();          // 2. 親要素への伝播を阻止
            e.stopImmediatePropagation(); // 3. 同じ要素に設定された他のリスナーも阻止
            navigator.clipboard.writeText(text).then(() => {
                const icon = btn.querySelector('.material-symbols-outlined');
                icon.textContent = 'done';
                setTimeout(() => {
                    icon.textContent = 'content_copy';
                }, 1500);
            });
        }, { capture: true, }); // キャプチャリングフェーズで先に捕まえる

        container.appendChild(btn);
        console.log(`[ALT Viewer] Button added: ${text.replace(/\n/g, ' ').substring(0, 80)}...`);
    }

    // --- API Core (Tokimeki 動画用) ---
    async function fetchVideoAltForTokimeki(videoWrap) {
        const contentNode = videoWrap.closest('.timeline__content');
        const uri = contentNode?.dataset.aturi;
        if (!uri) {
            return '';
        }

        try {
            const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0`;
            const res = await fetch(apiUrl);
            if (!res.ok) {
                return '';
            }

            const data = await res.json();
            const post = data.thread?.post;
            // console.log('[Debug] post.embed full structure:', JSON.stringify(post.embed, null, 2));

            // 動画のALTを優先的に、なければ埋め込みのALTを取得
            const altText = post.embed?.external?.title ||
                post.embed?.media?.external?.title ||
                post.embed?.media?.alt ||
                post.embed?.video?.alt ||
                post.embed?.alt || '';
            return altText;
        } catch (e) {
            console.error('[ALT-Script] Tokimeki API Fetch Error:', e);
            return '';
        }
    }

    // ポスト要素を解析し、ターゲットを探す
    function processPost(post) {
        // console.log(`[Debug] processPost: 要素名=${post.tagName}, クラス=${post.className}, 画像数=${post.querySelectorAll(currentPlatform.image).length}`);
        currentPlatform.targets.forEach(async (cfg) => {
            // 1. まずコンテナ(ボタンを置くべき親要素)を探す
            const containers = post.querySelectorAll(cfg.containerSelector);
            // console.log(`[Debug] 見つかったコンテナ数: ${containers.length}個`);
            containers.forEach(async (con) => {
                if (!con || con.querySelector('.alt-button')) {
                    return;
                }

                // Tokimekiかつ動画コンテナの場合のみ、属性をAPIから補完
                if (currentPlatform.name === 'Tokimeki' && cfg.containerSelector.includes('.timeline-video-wrap')) {
                    // 1. 【対象チェック】中身に動画プレイヤーがないなら無視(偽物や空枠を弾く)
                    if (!con.querySelector('video, .video-player')) {
                        return;
                    }
                    // 2. 【状態チェック】すでに取得済み、または現在取得中なら無視(二重処理を弾く)
                    if (con.dataset.fetching === 'true' || isValidAltText(con.getAttribute('alt'))) {
                        return;
                    }

                    // API処理
                    con.dataset.fetching = 'true';
                    const fetchedAlt = await fetchVideoAltForTokimeki(con);

                    if (isValidAltText(fetchedAlt)) {
                        con.setAttribute('alt', fetchedAlt);
                        createAltButton(con, fetchedAlt, cfg.position);
                    }
                    con.dataset.fetching = 'false';
                    return;
                }

                // 2. コンテナ内でテキストを持つ要素を特定する
                const el = cfg.textSelector ? con.querySelector(cfg.textSelector) : con;
                // 3. テキスト取得
                const txt = getAltText(el, cfg.attr);
                if (isValidAltText(txt)) {
                    // 極端に小さい要素(アイコン等)は除外
                    if (con.offsetWidth > 0 && con.offsetWidth < 40) {
                        return;
                    }
                    // console.log(`[Debug] textSelector: ${el}, text:${txt.replace(/\n/g, ' ').substring(0, 80)}...`);
                    // el.style.border = "1px solid red";
                    // con.style.border = "2px solid lightblue"; 枠線を綺麗に付けるにはconの6階層上
                    createAltButton(con, txt, cfg.position);
                }
            });
        });
    }

    // --- 監視 ---
    const observer = new MutationObserver(mutations => {
        for (const m of mutations) {
            // 新規追加ノードの処理
            m.addedNodes.forEach(node => {
                if (node.nodeType === 1) {
                    if (node.matches && node.matches(currentPlatform.root)) {
                        processPost(node);
                    } else {
                        node.querySelectorAll(currentPlatform.root).forEach(processPost);
                    }
                }
            });
            // 既存ポスト内の変化(画像が遅れて出た場合など)をキャッチ
            const post = m.target.nodeType === 1 ? m.target.closest(currentPlatform.root) : null;
            if (post) {
                processPost(post);
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true, });

    // 初期実行
    window.addEventListener('load', () => {
        setTimeout(() => document.querySelectorAll(currentPlatform.root).forEach(processPost), 1000);
    });
})();