// ==UserScript== // @name Poe积分显示 // @namespace http://tampermonkey.net/ // @version 1.4 // @author xiadengma // @description 在每次对话的下方显示当前积分和本次对话消耗的积分,并在页面加载时显示最新积分 // @match *://poe.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @icon https://psc2.cf2.poecdn.net/assets/favicon.svg // @updateURL https://gist.githubusercontent.com/XIADENGMA/62e1239fdbd9c9b7ca0da285c2756fd1/raw/Poe_show_points_usage.user.js // @downloadURL https://gist.githubusercontent.com/XIADENGMA/62e1239fdbd9c9b7ca0da285c2756fd1/raw/Poe_show_points_usage.user.js // ==/UserScript== (function () { 'use strict'; const DEBUG = false; const log = DEBUG ? console.log.bind(console, '[Poe积分显示]') : () => { }; const error = DEBUG ? console.error.bind(console, '[Poe积分显示]') : () => { }; const SELECTORS = { messagePair: '.ChatMessagesView_messageTuple__Jh5lQ', inputMessage: '.Message_rightSideMessageBubble__ioa_i', outputMessage: '.Message_leftSideMessageBubble__VPdk6', stopButton: 'button[aria-label="停止信息"]', actionBar: 'section.ChatMessageActionBar_actionBar__gyeEs', pointsElement: '.SettingsSubscriptionSection_computePointsValue___DLOM', resetElement: '.SettingsSubscriptionSection_subtext__cZuI6', messagePointLimitElement: '.DefaultMessagePointLimit_computePointsValue__YYJkB' }; const CONFIG = { checkInterval: 200, stableCount: 1, cacheExpiry: 5 * 60 * 1000, retryLimit: 3, retryDelay: 1000, maxPointsFetchAttempts: 5 }; const state = { pointsBeforeOutput: null, resetDate: '', processedInputNodes: new WeakSet(), processedOutputNodes: new WeakSet(), initialLoadCompleted: false, isFetching: false, isInitialized: false, observer: null, }; GM_addStyle(` .points-info { font-size: 12px; padding: 8px 16px; margin: 8px 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: rgba(255, 255, 255, 0.05); border-radius: 8px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; } .points-info-highlight { color: #fff !important; } `); const throttle = (func, limit) => { let lastFunc; let lastRan; return function () { const context = this; const args = arguments; if (!lastRan) { func.apply(context, args); lastRan = Date.now(); } else { clearTimeout(lastFunc); lastFunc = setTimeout(function () { if ((Date.now() - lastRan) >= limit) { func.apply(context, args); lastRan = Date.now(); } }, limit - (Date.now() - lastRan)); } } } async function fetchPoints(retryCount = 0) { if (state.isFetching) { await new Promise(resolve => setTimeout(resolve, 100)); return fetchPoints(retryCount); } state.isFetching = true; try { log('正在获取积分信息...'); const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: 'https://poe.com/settings', onload: resolve, onerror: reject }); }); const doc = new DOMParser().parseFromString(response.responseText, 'text/html'); const pointsElement = doc.querySelector(SELECTORS.pointsElement); const resetElement = doc.querySelector(SELECTORS.resetElement); const messagePointLimitElement = doc.querySelector(SELECTORS.messagePointLimitElement); if (!pointsElement) { throw new Error('积分元素丢失'); } const currentPoints = parseInt(pointsElement.textContent.replace(/,/g, ''), 10); log('当前积分:', currentPoints); if (resetElement) { state.resetDate = resetElement.textContent.trim(); log('重置时间:', state.resetDate); } if (messagePointLimitElement) { state.messagePointLimit = parseInt(messagePointLimitElement.textContent.replace(/,/g, ''), 10); log('全局单条信息预算:', state.messagePointLimit); } state.pointsBeforeOutput = currentPoints; return currentPoints; } catch (err) { error('获取积分信息失败', err); if (retryCount < CONFIG.retryLimit) { log(`重试获取积分 (${retryCount + 1}/${CONFIG.retryLimit})...`); await new Promise(resolve => setTimeout(resolve, CONFIG.retryDelay)); return fetchPoints(retryCount + 1); } throw err; } finally { state.isFetching = false; } } function monitorMessages() { log('开始监听消息...'); if (state.observer) { state.observer.disconnect(); } state.observer = new MutationObserver(throttledHandleMutations); state.observer.observe(document.body, { childList: true, subtree: true }); detectInitialLoadCompletion(); } function detectInitialLoadCompletion() { let messageCount = 0; let lastMessageCount = 0; let stableCount = 0; const checkComplete = () => { messageCount = document.querySelectorAll(SELECTORS.messagePair).length; if (messageCount === lastMessageCount) { if (++stableCount >= CONFIG.stableCount) { log('初始加载完成,开始忽略历史消息'); state.initialLoadCompleted = true; displayLatestPointsInfo(); return; } } else { stableCount = 0; } lastMessageCount = messageCount; setTimeout(checkComplete, CONFIG.checkInterval); }; checkComplete(); } const throttledHandleMutations = throttle(handleMutations, 200); function handleMutations(mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const messagePair = node.closest(SELECTORS.messagePair); if (messagePair) { processMessagePair(messagePair); } } } } } const isMessageGenerating = () => !!document.querySelector(SELECTORS.stopButton); function waitForMessageCompletion(outputMessage) { return new Promise(resolve => { let lastContent = outputMessage.textContent; let stableCount = 0; const checkComplete = () => { const currentContent = outputMessage.textContent; if (currentContent === lastContent && !isMessageGenerating()) { if (++stableCount >= CONFIG.stableCount) { log('消息输出已完成'); resolve(); return; } } else { stableCount = 0; } lastContent = currentContent; setTimeout(checkComplete, CONFIG.checkInterval); }; checkComplete(); }); } async function processMessagePair(messagePair) { if (!state.isInitialized || !state.initialLoadCompleted) { log('脚本尚未完全初始化或页面未加载完成,跳过消息处理'); return; } const inputMessage = messagePair.querySelector(SELECTORS.inputMessage); if (inputMessage && !state.processedInputNodes.has(inputMessage)) { log('检测到新的输入消息'); state.processedInputNodes.add(inputMessage); log('输入前积分:', state.pointsBeforeOutput); } const outputMessage = messagePair.querySelector(SELECTORS.outputMessage); if (outputMessage && !state.processedOutputNodes.has(outputMessage)) { if (!outputMessage.textContent.trim()) { log('输出消息尚未完整,等待加载...'); return; } log('检测到新的输出消息'); state.processedOutputNodes.add(outputMessage); const pointsBeforeOutput = state.pointsBeforeOutput; log('输出前积分:', pointsBeforeOutput); try { await waitForMessageCompletion(outputMessage); log('消息已完全输出,等待积分更新...'); await new Promise(resolve => setTimeout(resolve, 500)); let pointsAfterOutput = pointsBeforeOutput; for (let i = 0; i < CONFIG.maxPointsFetchAttempts; i++) { const newPoints = await fetchPoints(); if (newPoints !== pointsBeforeOutput) { pointsAfterOutput = newPoints; break; } log(`第 ${i + 1} 次尝试获取积分,未发现变化`); if (i < CONFIG.maxPointsFetchAttempts - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } const pointsUsed = pointsBeforeOutput - pointsAfterOutput; log('输出后积分:', pointsAfterOutput); log('本次对话消耗积分:', pointsUsed); if (pointsUsed > 0) { displayPointsInfo(messagePair, pointsAfterOutput, pointsUsed); } state.pointsBeforeOutput = pointsAfterOutput; } catch (err) { error('积分更新或消息完成失败:', err); } } } function displayPointsInfo(messagePair, currentPoints, pointsUsed) { if (messagePair.querySelector('.points-info')) return; log('显示积分信息'); const pointsInfo = createPointsInfoElement(currentPoints, pointsUsed); const actionBar = messagePair.querySelector(SELECTORS.actionBar); if (actionBar) { actionBar.parentNode.insertBefore(pointsInfo, actionBar); } else { messagePair.appendChild(pointsInfo); } } function createPointsInfoElement(currentPoints, pointsUsed = null, isInitialLoad = false) { const pointsInfo = document.createElement('div'); pointsInfo.className = 'points-info'; const infoItems = [ { text: `重置时间: ${state.resetDate}`, color: '#555' }, { text: `当前积分: ${currentPoints.toLocaleString()}`, color: '#888', highlight: isInitialLoad } ]; if (pointsUsed !== null) { infoItems.push({ text: `本次消耗积分: ${pointsUsed}`, color: '#fff' }); } pointsInfo.innerHTML = infoItems.map(item => `<div style="color: ${item.color}" ${item.highlight ? 'class="points-info-highlight"' : ''}>${item.text}</div>` ).join(''); return pointsInfo; } async function displayLatestPointsInfo() { const messagePairs = document.querySelectorAll(SELECTORS.messagePair); if (messagePairs.length > 0) { const lastMessagePair = messagePairs[messagePairs.length - 1]; const currentPoints = await fetchPoints(); const pointsInfo = createPointsInfoElement(currentPoints, null, true); const existingPointsInfo = lastMessagePair.querySelector('.points-info'); if (existingPointsInfo) { existingPointsInfo.replaceWith(pointsInfo); } else { const actionBar = lastMessagePair.querySelector(SELECTORS.actionBar); if (actionBar) { actionBar.parentNode.insertBefore(pointsInfo, actionBar); } else { lastMessagePair.appendChild(pointsInfo); } } } } function handleUrlChange() { log('URL已更改,重置状态'); state.processedInputNodes = new WeakSet(); state.processedOutputNodes = new WeakSet(); state.initialLoadCompleted = false; state.pointsBeforeOutput = null; init(); } async function init() { try { const initialPoints = await fetchPoints(); state.pointsBeforeOutput = initialPoints; state.isInitialized = true; log('初始化完成,当前积分:', initialPoints); monitorMessages(); displayLatestPointsInfo(); // 在初始化时显示最新积分信息 window.addEventListener('popstate', handleUrlChange); let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; handleUrlChange(); } }).observe(document, { subtree: true, childList: true }); } catch (err) { error('初始化失败:', err); } } init(); })();