您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays the number of OT! forum posts below the user's regular post count.
// ==UserScript== // @name OT! Post Counter v1.1 // @namespace http://tampermonkey.net/ // @version 1.1 // @description Displays the number of OT! forum posts below the user's regular post count. // @author Behrauder // @match https://osu.ppy.sh/* // @license MIT // @grant GM_xmlhttpRequest // @run-at document-end // ==/UserScript== (function () { 'use strict'; // —— Hook into History API and dispatch a custom event on URL changes —— ['pushState','replaceState'].forEach(method => { const orig = history[method]; history[method] = function(...args) { const ret = orig.apply(this, args); window.dispatchEvent(new Event('locationchange')); return ret; }; }); // Debounced navigation handler let debounceTimer; function onNavChange() { clearTimeout(debounceTimer); debounceTimer = setTimeout(scanContainers, 200); } // Listen for navigation events using the debounced handler window.addEventListener('popstate', onNavChange); window.addEventListener('locationchange', onNavChange); document.addEventListener('pjax:end', onNavChange); // Toggle verbose debug logging const DEBUG = false; /** * Conditional logger: prints only if DEBUG is true */ function log(...args) { if (DEBUG) console.log(...args); } // Time-to-live for cached counts (milliseconds) const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // Initial delay between requests (milliseconds) const INIT_DELAY_MS = 300; // Maximum random additional delay (milliseconds) const JITTER_MS = 150; // Factor by which to back off delay on failures const BACKOFF_FACTOR = 1.6; /** * Retrieve a cached count for the given URL if still valid. * @param {string} url * @returns {number|null} */ function cacheGet(url) { const raw = localStorage.getItem(url); if (!raw) return null; try { const { count, ts } = JSON.parse(raw); if (Date.now() - ts < CACHE_TTL_MS) return count; } catch (e) {} localStorage.removeItem(url); return null; } /** * Store a count in cache with current timestamp. * @param {string} url * @param {number} count */ function cacheSet(url, count) { localStorage.setItem(url, JSON.stringify({ count, ts: Date.now() })); } // Treat queue as a deque for pending forum link fetches const queue = []; let isProcessing = false; let delay = INIT_DELAY_MS; // Flag to alternate between taking from front/back let takeFromBack = false; function processQueue() { if (!queue.length) { isProcessing = false; log('[queue] empty'); return; } isProcessing = true; // alternate between popping from the end and shifting from the start const { forumLink } = takeFromBack ? queue.pop() : queue.shift(); log(`[queue] fetching ${forumLink} (remaining ${queue.length})`); fetchPostCount(forumLink) .then(count => { log(`[fetch] ${forumLink} → ${count}`); cacheSet(forumLink, count); const lista = linkContainers.get(forumLink) || []; lista.forEach(container => insert(container, forumLink, count)); delay = Math.max(200, delay / BACKOFF_FACTOR); }) .catch(err => { console.warn(`[fetch] failed ${forumLink} →`, err); delay *= BACKOFF_FACTOR; queue.push({ forumLink }); }) .finally(() => { const wait = delay + Math.random() * JITTER_MS; log(`[queue] next in ${Math.round(wait)} ms`); setTimeout(processQueue, wait); }); } // Map to track containers for each forum link and set of requested links const linkContainers = new Map(); const requested = new Set(); /** * Scan page for user post info elements and enqueue fetches as needed. */ function scanContainers() { document.querySelectorAll('.forum-post-info__row--posts').forEach(c => { if (c.dataset.added) return; const linkElem = c.querySelector('a'); const href = linkElem && linkElem.href; const m = href && href.match(/\/users\/\d+\/posts/); if (!m) return; const forumLink = `https://osu.ppy.sh${m[0]}?forum_id=52`; c.dataset.added = 'true'; if (!linkContainers.has(forumLink)) { linkContainers.set(forumLink, []); } linkContainers.get(forumLink).push(c); const cached = cacheGet(forumLink); if (cached !== null) { insert(c, forumLink, cached); } else if (!requested.has(forumLink)) { requested.add(forumLink); queue.push({ forumLink }); if (!isProcessing) processQueue(); } }); } // Observe DOM changes to detect new post info rows const observer = new MutationObserver(scanContainers); observer.observe(document.body, { childList: true, subtree: true }); // Initial scan scanContainers(); /** * Insert the post count link into the container element. * @param {Element} container * @param {string} forumLink * @param {number} count */ function insert(container, forumLink, count) { const formatted = count >= 10000 ? '10000+' : count.toLocaleString(); const label = count === 1 ? 'post' : 'posts'; const br = document.createElement('br'); const a = document.createElement('a'); a.href = forumLink; a.textContent = `OT: ${formatted} ${label}`; a.style.fontWeight = 'bold'; container.appendChild(br); container.appendChild(a); } /** * Fetch the total number of posts from the forum by performing up to two requests. * @param {string} baseUrl * @returns {Promise<number>} */ function fetchPostCount(baseUrl) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: baseUrl, onload({ status, responseText }) { // Handle rate limiting or Cloudflare checks if (status === 429 || /<title>.*(Access Denied|Just a moment).*<\/title>/i.test(responseText)) { return reject('blocked'); } if (status !== 200) return reject(`status1-${status}`); const parser = new DOMParser(); const doc = parser.parseFromString(responseText, 'text/html'); const items = [...doc.querySelectorAll('.pagination-v2__item a')]; let lastPage = 1; items.forEach(a => { const mm = a.href.match(/page=(\d+)/); if (mm) lastPage = Math.max(lastPage, +mm[1]); }); // Fetch the last page to count entries const url2 = `${baseUrl}&page=${lastPage}`; GM_xmlhttpRequest({ method: 'GET', url: url2, onload({ status: st2, responseText: txt2 }) { if (st2 !== 200) return reject(`status2-${st2}`); const doc2 = parser.parseFromString(txt2, 'text/html'); const countOnLast = doc2.querySelectorAll('.search-entry').length; resolve((lastPage - 1) * 50 + countOnLast); }, onerror: () => reject('network2') }); }, onerror: () => reject('network1') }); }); } // Fallback: scan every 2 seconds in case something slips past the observer setInterval(scanContainers, 2000); })();