您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
发送后右下角开始计时,回复完成即停止;通过 fetch/XHR 拦截 + DOM 观察,适配 ChatGPT 动态页面
// ==UserScript== // @name ChatGPT Timer // @namespace https://github.com/lueluelue2006 // @version 3.0 // @description 发送后右下角开始计时,回复完成即停止;通过 fetch/XHR 拦截 + DOM 观察,适配 ChatGPT 动态页面 // @author schweigen // @match https://chatgpt.com/ // @match https://chatgpt.com/c/* // @match https://chatgpt.com/g/* // @match https://chatgpt.com/share/* // @run-at document-start // @grant none // @inject-into page // @license MIT // ==/UserScript== /* 设计要点(简述): - 主信号:拦截 window.fetch / XMLHttpRequest,识别对会话接口的请求,开始计时;当响应流结束(SSE 流完)时停止计时。 - 兜底信号:DOM 观察 [data-testid="stop-button"] 按钮的出现/消失;出现→忙碌,消失→空闲。 - 合并逻辑:任一信号认为“忙碌”则启动计时,全部空闲才停止,避免误判/并发请求。 - 以 postMessage 在页面上下文与内容脚本之间通信,确保跨沙箱稳定;同时 @inject-into page 让 fetch 补丁尽早生效。 */ (function () { 'use strict'; // ---------------------- 工具:字符串匹配 ---------------------- const GEN_URL_PATTERNS = [ /\/backend-api\/conversation(\b|\/)/i, /\/backend-api\/messages(\b|\/)/i, /\/client-bff\/conversation(\b|\/)/i, /\/backend-api\/generate(\b|\/)/i, /\/api\/conversation(\b|\/)/i, /\/api\/messages(\b|\/)/i, /\/v1\/messages(\b|\/)/i ]; function likelyGeneration(url, method, headers, body) { try { const m = (method || 'GET').toUpperCase(); if (!(m === 'POST' || m === 'PATCH')) return false; if (typeof url === 'string') { if (!GEN_URL_PATTERNS.some(re => re.test(url))) return false; } else if (url && typeof url.url === 'string') { if (!GEN_URL_PATTERNS.some(re => re.test(url.url))) return false; } else { return false; } // 额外温和的启发式:Accept/Content-Type const h = normalizeHeaders(headers); const accept = (h['accept'] || '').toLowerCase(); const ctype = (h['content-type'] || '').toLowerCase(); if (accept.includes('text/event-stream')) return true; if (ctype.includes('application/json')) return true; return true; // 保守:匹配到 URL 即认为是生成 } catch (_) { return false; } } function normalizeHeaders(h) { const out = {}; if (!h) return out; try { // Headers 对象 if (typeof Headers !== 'undefined' && h instanceof Headers) { for (const [k, v] of h.entries()) out[k.toLowerCase()] = String(v); return out; } } catch (_) {} // 普通对象/数组 try { if (Array.isArray(h)) { for (const [k, v] of h) out[String(k).toLowerCase()] = String(v); return out; } if (typeof h === 'object') { for (const k of Object.keys(h)) out[k.toLowerCase()] = String(h[k]); return out; } } catch (_) {} return out; } // ---------------------- 页面内注入:拦截 fetch/XHR ---------------------- // 用 <script> 注入到 page 上下文,这样能可靠补丁 window.fetch,不影响页面原逻辑。 function injectNetworkHooks() { const s = document.createElement('script'); s.textContent = `(() => { const post = (type, detail) => { try { window.postMessage({ source: 'gpt-reply-timer', type, detail }, '*'); } catch (_) {} }; const patterns = [${GEN_URL_PATTERNS.map(r => r.toString()).join(',')}]; const normHeaders = ${normalizeHeaders.toString()}; const isGen = (url, method, headers, body) => { try { const m = (method || 'GET').toUpperCase(); if (!(m === 'POST' || m === 'PATCH')) return false; const urlStr = (typeof url === 'string') ? url : (url && url.url) || ''; if (!patterns.some(re => re.test(urlStr))) return false; const h = normHeaders(headers); const accept = (h['accept'] || '').toLowerCase(); const ctype = (h['content-type'] || '').toLowerCase(); if (accept.includes('text/event-stream')) return true; if (ctype.includes('application/json')) return true; return true; } catch (_) { return false; } }; // ---- fetch ---- try { const origFetch = window.fetch; if (origFetch) { window.fetch = function(input, init) { let url = input; let method = (init && init.method) || (input && input.method) || 'GET'; const headers = (init && init.headers) || (input && input.headers); const body = (init && init.body) || (input && input.body); const track = isGen(url, method, headers, body); if (track) post('start', { via: 'fetch' }); const p = origFetch.apply(this, arguments); if (track) { p.then(res => { try { const c = res.clone(); if (c.body && c.body.getReader) { const reader = c.body.getReader(); const readLoop = () => reader.read().then(({ done }) => { if (done) { post('stop', { via: 'fetch', how: 'stream-end' }); return; } return readLoop(); }).catch(() => post('stop', { via: 'fetch', how: 'stream-error' })); readLoop(); } else { c.text().finally(() => post('stop', { via: 'fetch', how: 'no-body' })); } } catch (_) { post('stop', { via: 'fetch', how: 'clone-failed' }); } }).catch(() => post('stop', { via: 'fetch', how: 'rejected' })); } return p; }; } } catch (_) {} // ---- XHR 兜底 ---- try { const OrigXHR = window.XMLHttpRequest; if (OrigXHR) { const Proto = OrigXHR.prototype; const _open = Proto.open; const _send = Proto.send; let last = new WeakMap(); Proto.open = function(method, url) { this.__gpt_timer_meta = { method, url }; return _open.apply(this, arguments); }; Proto.send = function(body) { const meta = this.__gpt_timer_meta || {}; const track = isGen(meta.url, meta.method, this._headers, body); if (track) post('start', { via: 'xhr' }); this.addEventListener('loadend', () => { if (track) post('stop', { via: 'xhr', how: 'loadend' }); }); return _send.apply(this, arguments); }; const _setReqHeader = Proto.setRequestHeader; Proto.setRequestHeader = function(k, v) { this._headers = this._headers || {}; this._headers[k] = v; return _setReqHeader.apply(this, arguments); }; } } catch (_) {} })();`; (document.head || document.documentElement).appendChild(s); s.remove(); } // ---------------------- UI:右下角计时器 ---------------------- function createTimerUI() { if (document.getElementById('gpt-reply-timer')) return; const wrap = document.createElement('div'); wrap.id = 'gpt-reply-timer'; Object.assign(wrap.style, { position: 'fixed', right: '12px', bottom: '12px', zIndex: '2147483647', background: 'rgba(20,20,20,0.9)', color: '#e6e6e6', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: '14px', lineHeight: '1', padding: '10px 12px', borderRadius: '10px', boxShadow: '0 4px 16px rgba(0,0,0,0.35)', userSelect: 'none', display: 'none', alignItems: 'center', gap: '8px', border: '1px solid rgba(255,255,255,0.12)' }); const dot = document.createElement('span'); Object.assign(dot.style, { display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%', background: '#ff5252', boxShadow: '0 0 6px rgba(255,82,82,0.8) inset' }); const text = document.createElement('span'); text.textContent = '00:00.0'; text.style.minWidth = '64px'; text.style.textAlign = 'right'; const label = document.createElement('span'); label.textContent = 'Replying'; label.style.opacity = '0.8'; wrap.appendChild(dot); wrap.appendChild(text); wrap.appendChild(label); document.documentElement.appendChild(wrap); return { wrap, dot, text, label }; } function formatElapsed(ms) { const total = Math.max(0, ms|0); const m = Math.floor(total / 60000); const s = Math.floor((total % 60000) / 1000); const d = Math.floor((total % 1000) / 100); // 1/10 秒 const mm = m.toString().padStart(2, '0'); const ss = s.toString().padStart(2, '0'); return `${mm}:${ss}.${d}`; } // ---------------------- 状态机:合并网络 + DOM 信号 ---------------------- let ui = null; let running = false; let startAt = 0; let rafId = 0; let netActive = 0; // 网络拦截活跃计数 let domBusy = false; // DOM 观察到 "Stop" 按钮存在 // 每条回复的定位与耗时记录 let currentTurnEl = null; // 本次生成对应的 assistant article let replyStartAt = 0; // 本次生成起始时间戳 function ensureUI() { if (!ui) ui = createTimerUI(); return ui; } function startTimer() { if (running) return; running = true; startAt = performance.now(); replyStartAt = startAt; currentTurnEl = null; // 等待观察器捕捉到本次新增的 assistant turn const { wrap, dot, text, label } = ensureUI(); wrap.style.display = 'flex'; dot.style.background = '#ff5252'; dot.style.boxShadow = '0 0 6px rgba(255,82,82,0.8) inset'; label.textContent = 'Replying'; const loop = () => { const now = performance.now(); text.textContent = formatElapsed(now - startAt); rafId = requestAnimationFrame(loop); }; rafId = requestAnimationFrame(loop); } function stopTimer() { if (!running) return; running = false; cancelAnimationFrame(rafId); const { dot, label } = ensureUI(); dot.style.background = '#4caf50'; dot.style.boxShadow = '0 0 6px rgba(76,175,80,0.8) inset'; label.textContent = 'Done'; // 不自动隐藏:保留耗时,便于查看;下次开始时复用 // 在对应的 assistant 消息工具栏上标注本次用时 try { const target = resolveReplyTarget(); if (target && replyStartAt) { const spent = Math.max(0, (performance.now() - replyStartAt) | 0); annotateTurnDuration(target, spent); } } catch (_) {} } function recompute() { const busy = (netActive > 0) || domBusy; if (busy) startTimer(); else stopTimer(); } // ---------------------- 监听:页面消息(来自 fetch/XHR 注入) ---------------------- window.addEventListener('message', (ev) => { const d = ev && ev.data; if (!d || d.source !== 'gpt-reply-timer') return; if (d.type === 'start') { netActive += 1; recompute(); } else if (d.type === 'stop') { netActive = Math.max(0, netActive - 1); recompute(); } }); // ---------------------- 监听:DOM(stop 按钮) ---------------------- function observeDom() { const update = () => { // ChatGPT 一直使用 data-testid 做测试钩子,相对稳定 const stopBtn = document.querySelector('[data-testid="stop-button"]'); domBusy = !!stopBtn; recompute(); }; const mo = new MutationObserver((muts) => { // 变更频繁,做一次聚合判断即可 update(); }); mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); // 初始探测 update(); } // ---------------------- 监听:DOM(assistant 新消息 + 标注恢复) ---------------------- function isAssistantArticle(el) { return el && el.tagName === 'ARTICLE' && el.getAttribute('data-turn') === 'assistant'; } function getLastAssistant() { const list = Array.from(document.querySelectorAll('article[data-turn="assistant"]')); return list.at(-1) || null; } function findToolbar(article) { if (!article) return null; const divs = article.querySelectorAll('div'); const candidates = Array.from(divs).filter(d => d.classList && d.classList.contains('flex') && d.classList.contains('justify-start') && d.querySelector('button[aria-haspopup="menu"]') ); return candidates.at(-1) || null; } function annotateTurnDuration(article, ms) { try { article.dataset.replyMs = String(ms); const tb = findToolbar(article); if (!tb) return; let btn = tb.querySelector('button[data-reply-duration="true"]'); if (!btn) { btn = document.createElement('button'); btn.dataset.replyDuration = 'true'; btn.setAttribute('aria-label', '回复耗时'); btn.className = 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg'; const inner = document.createElement('span'); inner.className = 'flex items-center justify-center h-8 px-2'; inner.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'; inner.style.fontVariantNumeric = 'tabular-nums'; inner.style.fontSize = '12px'; btn.appendChild(inner); // 插到“三点”按钮左侧;否则追加到末尾 const kebab = tb.querySelector('button[aria-haspopup="menu"]') || Array.from(tb.querySelectorAll('button')).at(-1); if (kebab && kebab.parentElement === tb) tb.insertBefore(btn, kebab); else tb.appendChild(btn); } const seconds = (ms / 1000).toFixed(1); const label = `⏱ ${seconds}s`; const span = btn.firstElementChild || btn; span.textContent = label; } catch (_) {} } function resolveReplyTarget() { // 优先使用观察到的本次新建 turn;否则回退到当前最后一条 assistant if (currentTurnEl && document.contains(currentTurnEl)) return currentTurnEl; return getLastAssistant(); } function observeAssistantTurns() { const mo = new MutationObserver((muts) => { for (const m of muts) { if (m.type !== 'childList') continue; for (const n of m.addedNodes) { if (isAssistantArticle(n)) { // 记录本次生成对应的节点 if (running && !currentTurnEl) currentTurnEl = n; // 若该节点已有耗时数据但未渲染徽标,尝试恢复 const ms = Number(n?.dataset?.replyMs || ''); if (ms > 0) annotateTurnDuration(n, ms); } // 深层嵌套新增时,尝试自底向上找到 article if (n.nodeType === 1) { const a = n.closest && n.closest('article[data-turn="assistant"]'); if (a && isAssistantArticle(a)) { if (running && !currentTurnEl) currentTurnEl = a; const ms = Number(a?.dataset?.replyMs || ''); if (ms > 0) annotateTurnDuration(a, ms); } } } } }); mo.observe(document.documentElement || document.body, { childList: true, subtree: true }); // 初始恢复:已存在的消息 const last = getLastAssistant(); if (last && last.dataset.replyMs) annotateTurnDuration(last, Number(last.dataset.replyMs)); } // ---------------------- 初始化 ---------------------- function onReady(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); } // 先注入网络拦截,尽量抢在 App 初始化之前 try { injectNetworkHooks(); } catch (_) {} // DOM 就绪后再安装 UI 与观察器 onReady(() => { ensureUI(); observeDom(); observeAssistantTurns(); }); })();