YouTube Community Exact Date & Time

Display exact "YYYY/MM/DD HH:mm:ss" date on YouTube community posts by fetching source HTML dynamically.

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

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           YouTube Community Exact Date & Time
// @name:ja        YouTube コミュニティ投稿日時を詳細表示
// @namespace      https://bsky.app/profile/neon-ai.art
// @homepage       https://github.com/neon-aiart
// @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>
// @version        1.4
// @description    Display exact "YYYY/MM/DD HH:mm:ss" date on YouTube community posts by fetching source HTML dynamically.
// @description:ja YouTubeのコミュニティ投稿に、ソースから取得した正確な日時(秒単位)を表示します。一覧・個別ページ両対応。
// @author         ねおん
// @match          https://*.youtube.com/*
// @grant          none
// @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 VERSION = '1.4';
    const STORE_KEY = 'youtube-community-exact-date-time';

    const DEBUG = false;

    let intersectionObserver = null;
    let mutationObserver = null;
    const fetchTimers = new Map();   // 高速スクロール時の過剰通信防止タイマー管理
    const completedUrls = new Map(); // 書き換え完了したURLと日時のペアを記憶

    function getFormattedDateTime() {
        const now = new Date();

        const pad = (num) => num.toString().padStart(2, '0'); // 2桁にする関数

        const y = now.getFullYear();
        const m = pad(now.getMonth() + 1);
        const d = pad(now.getDate());
        const h = pad(now.getHours());
        const min = pad(now.getMinutes());
        const s = pad(now.getSeconds());

        return `${y}/${m}/${d} ${h}:${min}:${s}`;
    }

    // 個別の fetch & 書き換えロジック
    async function fetchAndReplace(targetLink, url) {
        if (!targetLink) {
            return;
        }

        // すでに過去に取得済みのURLなら、fetchせずに記憶から一瞬で書き換える
        if (completedUrls.has(url)) {
            // 記憶から戻すときも、ツールチップに元の相対日時をセットしておく
            const data = completedUrls.get(url);
            targetLink.title = data.originalText;
            targetLink.textContent = data.formatted;
            return;
        }

        try {
            const response = await fetch(url);
            const html = await response.text();
            const match = html.match(/"datePublished"\s*:\s*"(.*?)"/);

            if (match && match[1]) {
                const d = new Date(match[1]);
                if (isNaN(d.getTime())) {
                    return;
                }

                const f = (n) => String(n).padStart(2, '0');
                const formatted =
                    `${d.getFullYear()}/${f(d.getMonth() + 1)}/${f(d.getDate())} ` +
                    `${f(d.getHours())}:${f(d.getMinutes())}:${f(d.getSeconds())}`;

                // 書き換える前の元のテキスト(「2 か月前」など)をツールチップ(title)に保存
                const originalText = targetLink.textContent;
                targetLink.title = originalText;

                targetLink.textContent = formatted;

                // 記憶するデータに元のテキストも一緒に含める
                completedUrls.set(url, { formatted, originalText, });

                if (DEBUG) {
                    console.log("✅ 詳細日時書き換え完了:", formatted);
                }
            }
        } catch (e) {
            console.error("日時取得失敗:", e);
        }
    }

    // 画面内に入った要素を処理する(IntersectionObserver のコールバック)
    function handleIntersection(entries) {
        entries.forEach(entry => {
            if (!entry.isIntersecting) {
                // 画面外に出たら fetch 予約をキャンセル(高速スクロール対策)
                if (fetchTimers.has(entry.target)) {
                    clearTimeout(fetchTimers.get(entry.target));
                    fetchTimers.delete(entry.target);
                }
                return;
            }

            // リンクから対象の個別URLを取得
            const targetLink = entry.target;
            const href = targetLink.getAttribute('href');
            if (!href || !href.includes('/post/')) {
                return;
            }
            const fullUrl = window.location.origin + href;

            if (DEBUG) {
                console.groupCollapsed(`[DEBUG] [${getFormattedDateTime()}] handleIntersection`);
                console.log('entry.target:', entry.target);
                console.log('targetLink:', targetLink);
                console.log('window.location.href:', window.location.href);
                console.log('window.location.origin + href:', fullUrl);
                console.groupEnd();
            }

            // 0.3秒画面に留まったら fetch 実行(スクロールで流し見している時は発火しない)
            const timer = setTimeout(() => {
                fetchAndReplace(targetLink, fullUrl);
                intersectionObserver.unobserve(targetLink); // 1回発火したら監視解除
                fetchTimers.delete(targetLink);
            }, 300);

            fetchTimers.set(targetLink, timer);
        });
    }

    // ページ内の「〇ヶ月前」リンクを探して監視対象に登録する
    function scanAndObserve() {
        // 現在のURLが /posts (一覧) または /post/ (個別) の場合のみ実行
        const isTargetPage = window.location.pathname.includes('/posts') || window.location.pathname.includes('/post/');
        if (!isTargetPage) {
            return;
        }

        // タイムスタンプ部分のリンク要素をすべて取得 (#published-time-text 内の a タグ)
        const links = document.querySelectorAll('#published-time-text a[href*="/post/"]');

        links.forEach(link => {
            // テキストにすでに「/」や「:」が含まれていれば、記憶になくても書き換え済みとみなす
            if (link.textContent.includes('/') || link.textContent.includes(':')) {
                return;
            }

            // ページ遷移時は observed フラグを無視して再登録できるようにする
            intersectionObserver.observe(link);
        });
    }

    // 初期化処理
    function init() {
        // 画面遷移のたびに、監視中フラグのリセットと再開を行う
        if (intersectionObserver) {
            intersectionObserver.disconnect();
        }
        if (mutationObserver) {
            mutationObserver.disconnect();
        }

        // 画面遷移時は、画面上の全リンクのobserved属性を綺麗に消し去る
        document.querySelectorAll('#published-time-text a').forEach(el => {
            el.removeAttribute('data-exact-date-observed');
        });

        // 1. 画面表示監視 (IntersectionObserver) の生成
        intersectionObserver = new IntersectionObserver(handleIntersection, {
            root: null,         // ブラウザのビューポートを基準にする
            rootMargin: '50px', // 画面に入る50px手前で少し早めに検知してカクつきを防ぐ
            threshold: 0,
        });

        // 2. DOM変化監視 (MutationObserver) の生成(追加ロード対策)
        mutationObserver = new MutationObserver(() => {
            scanAndObserve();
        });

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

        // 初回スキャンを実行
        scanAndObserve();
    }

    // YouTubeのSPA(画面遷移)イベントに対応
    window.addEventListener('yt-page-data-updated', init);

    // 初回読み込み時
    init();
})();