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);
})();