OT! Post Counter v1.1

Displays the number of OT! forum posts below the user's regular post count.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);

})();