您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays the timestamp at the end of each ChatGPT message.
// ==UserScript== // @name ChatGPT Timestamp Injector // @namespace https://kuds.win/ // @version 1.0 // @description Displays the timestamp at the end of each ChatGPT message. // @author KUDs // @match https://chatgpt.com/* // @icon https://chatgpt.com/favicon.ico // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; /** フォーマット済み日時文字列を取得する関数 */ function formatTimestamp(date) { // 各数値を2桁ゼロ埋め(年以外) const pad = (n) => n.toString().padStart(2, '0'); const YYYY = date.getFullYear(); const M = date.getMonth() + 1; const D = date.getDate(); const HH = pad(date.getHours()); const mm = pad(date.getMinutes()); const ss = pad(date.getSeconds()); return `${YYYY}/${M}/${D} ${HH}:${mm}:${ss}`; } /** チャットIDをURLから取得する関数 */ function getCurrentChatId() { // パス形式: /c/<chat-id> または /g/<team-id>/c/<chat-id> (また、/share/<share-id>は今回は除外) const match = location.pathname.match(/^\/(?:c|g\/[^\/]+\/c)\/([^\/]+)/); return match ? match[1] : null; } let accessToken = null; /** アクセストークンを取得(既に取得済みなら再利用) */ async function getAccessToken() { if (accessToken) return accessToken; try { const res = await fetch('/api/auth/session'); if (!res.ok) throw new Error(res.statusText); const data = await res.json(); accessToken = data.accessToken; return accessToken; } catch (err) { console.error('Failed to get access token:', err); return null; } } /** 会話データを取得する関数 */ async function fetchConversationData(chatId) { const token = await getAccessToken(); if (!token) return null; try { const res = await fetch(`/backend-api/conversation/${chatId}`, { headers: { 'Authorization': `Bearer ${token}`, 'X-Authorization': `Bearer ${token}` } }); if (!res.ok) throw new Error(res.statusText); return await res.json(); } catch (err) { console.error('Failed to fetch conversation data:', err); return null; } } /** メッセージ要素にタイムスタンプを付与する関数 */ function appendTimestampElement(messageElem, timestamp) { // **重複防止**:すでに挿入済みならスキップ if (messageElem.querySelector('time.chatgpt-timestamp')) return; // 時刻をフォーマットし<time>要素を生成 const date = new Date(timestamp * 1000); const timeText = formatTimestamp(date); const timeEl = document.createElement('time'); timeEl.className = 'chatgpt-timestamp w-full'; timeEl.dateTime = date.toISOString(); timeEl.title = date.toLocaleString(); timeEl.textContent = timeText; // 役割に応じて左右寄せを切り替え const role = messageElem.getAttribute('data-message-author-role'); Object.assign(timeEl.style, { fontStyle: 'italic', opacity: '0.6', fontSize: '0.875rem', display: 'block', textAlign: role === 'user' ? 'right' : 'left' }); messageElem.appendChild(timeEl); } // 挿入済みメッセージIDのセット(重複防止) const processedIds = new Set(); // MutationObserverの設定(新しいメッセージの追加を監視) const mainElem = document.querySelector('main'); let suppressObserver = false; let suppressedQueue = []; const observer = new MutationObserver((mutations) => { for (const mut of mutations) { for (const node of mut.addedNodes) { if (!(node instanceof HTMLElement)) continue; // 追加されたノード(またはその子孫)にメッセージ要素があるか確認 let messageElements = []; if (node.hasAttribute && node.hasAttribute('data-message-id')) { messageElements.push(node); } else { messageElements = Array.from(node.querySelectorAll?.('[data-message-id]') || []); } for (const msgElem of messageElements) { const msgId = msgElem.getAttribute('data-message-id'); if (!msgId || processedIds.has(msgId)) continue; if (suppressObserver) { // 一時抑制中はキューに溜めるだけ suppressedQueue.push(msgId); } else { // 新規メッセージに対しタイムスタンプを取得・表示 updateMessageTimestamp(msgElem, msgId); } } } } }); if (mainElem) { observer.observe(mainElem, { childList: true, subtree: true }); } /** 特定のメッセージIDに対してタイムスタンプを取得し挿入する関数 */ async function updateMessageTimestamp(messageElem, messageId, retryCount = 0) { const chatId = getCurrentChatId(); if (!chatId) return; const convo = await fetchConversationData(chatId); if (!convo || !convo.mapping) return; const node = convo.mapping[messageId]; if (!node || !node.message || node.message.create_time === undefined) { // 該当メッセージが会話データに無い(※生成中の可能性)場合、一定回数リトライ if (retryCount < 10) { setTimeout(() => updateMessageTimestamp(messageElem, messageId, retryCount + 1), 1000); } else { console.warn(`Timestamp not found for message ${messageId}`); } } else { // タイムスタンプ挿入 appendTimestampElement(messageElem, node.message.create_time); processedIds.add(messageId); } } /** 現在の会話内の全メッセージにタイムスタンプを付与 */ async function processAllMessagesInConversation() { const chatId = getCurrentChatId(); if (!chatId) return; const convo = await fetchConversationData(chatId); if (!convo) return; // 会話データから順序通りメッセージのリストを取得 const mapping = convo.mapping || {}; const startNodeId = convo.current_node || Object.values(mapping).find(n => !n.children || n.children.length === 0)?.id; if (!startNodeId) { console.warn('No valid start node for conversation'); return; } // 末尾(最新)から親をたどって最初のメッセージまでリスト化 const nodes = []; let nodeId = startNodeId; while (nodeId) { const node = mapping[nodeId]; if (!node) break; // ルートに到達 if (node.parent === undefined) break; // systemや非表示のメタメッセージをスキップ const msg = node.message; if (msg && msg.author.role !== 'system' && msg.content?.content_type !== 'model_editable_context' && msg.content?.content_type !== 'user_editable_context') { nodes.unshift(node); } nodeId = node.parent; } // DOM上の各メッセージ要素に対応するタイムスタンプを付与 for (const node of nodes) { const msg = node.message; if (!msg || msg.id === undefined || msg.create_time === undefined) continue; const msgId = msg.id; const elem = document.querySelector(`[data-message-id="${msgId}"]`); if (elem && !processedIds.has(msgId)) { appendTimestampElement(elem, msg.create_time); processedIds.add(msgId); } } } // チャットの切り替えや初期読み込みを監視し、適宜タイムスタンプを挿入 let currentChatId = null; setInterval(() => { const chatId = getCurrentChatId(); if (chatId && chatId !== currentChatId) { // チャットが新規に開かれた/切り替わった currentChatId = chatId; processedIds.clear(); suppressObserver = true; suppressedQueue = []; // 少し待ってから過去メッセージにまとめてタイムスタンプを付与 setTimeout(async () => { await processAllMessagesInConversation(); // 抑制中に溜まった新規メッセージも処理する for (const newId of suppressedQueue) { const elem = document.querySelector(`[data-message-id="${newId}"]`); if (elem && !processedIds.has(newId)) { updateMessageTimestamp(elem, newId); } } suppressedQueue = []; suppressObserver = false; }, 500); } }, 1000); })();