MusicBrainz: Reports Statistics

Indicates report changes since the last visit and hides reports without items.

// ==UserScript==
// @name        MusicBrainz: Reports Statistics
// @namespace   https://musicbrainz.org/user/chaban
// @version     2.0.1
// @description Indicates report changes since the last visit and hides reports without items.
// @tag         ai-created
// @author      chaban
// @license     MIT
// @match       *://*.musicbrainz.org/reports*
// @connect     musicbrainz.org
// @icon        https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant       GM_xmlhttpRequest
// @grant       GM_info
// ==/UserScript==

(function() {
    'use strict';

    const currentScriptVersion = GM_info.script.version;
    const CURRENT_CACHE_VERSION = '2.0';
    const SCRIPT_NAME = GM_info.script.name;
    const INTERNAL_CACHE_DURATION = 1 * 60 * 60 * 1000;
    const REQUEST_DELAY = 1000;
    const HISTORY_MAX_DAYS = 30;

    const MB_REPORT_GENERATION_HOUR_UTC = 0;
    const MB_REPORT_GENERATION_MINUTE_UTC = 10;

    const CENTRAL_CACHE_KEY = 'musicbrainz_reports_cache';

    let progressBarContainer;
    let progressBar;
    let totalLinksToFetch = 0;
    let fetchedLinksCount = 0;
    let currentFilterMode;

    /**
     * Custom logging function to prefix all messages with script name.
     * @param {...any} messages The messages to log.
     */
    function log(...messages) {
        console.log(`[${SCRIPT_NAME}]`, ...messages);
    }

    /**
     * Custom error logging function.
     * @param {...any} messages The error messages to log.
     */
    function error(...messages) {
        console.error(`[${SCRIPT_NAME}] ERROR:`, ...messages);
    }

    /**
     * Creates and initializes the progress bar elements.
     */
    function createProgressBar() {
        progressBarContainer = document.createElement('div');
        progressBarContainer.id = 'mb-report-hider-progress-container';
        Object.assign(progressBarContainer.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100%',
            height: '8px',
            backgroundColor: '#e0e0e0',
            zIndex: '9999',
            display: 'none'
        });

        progressBar = document.createElement('div');
        progressBar.id = 'mb-report-hider-progress-bar';
        Object.assign(progressBar.style, {
            width: '0%',
            height: '100%',
            backgroundColor: '#4CAF50',
            transition: 'width 0.3s ease-in-out'
        });

        progressBarContainer.appendChild(progressBar);
        document.documentElement.appendChild(progressBarContainer);
    }

    /**
     * Updates the progress bar's width.
     */
    function updateProgressBar() {
        if (totalLinksToFetch === 0) {
            progressBar.style.width = '0%';
        } else {
            const percentage = (fetchedLinksCount / totalLinksToFetch) * 100;
            progressBar.style.width = `${percentage}%`;
        }
    }

    /**
     * Shows the progress bar.
     */
    function showProgressBar() {
        if (progressBarContainer) {
            progressBarContainer.style.display = 'block';
        }
    }

    /**
     * Hides the progress bar.
     */
    function hideProgressBar() {
        if (progressBarContainer) {
            progressBarContainer.style.display = 'none';
        }
    }

    /**
     * Extracts just the report name from a full MusicBrainz report URL.
     * E.g., "https://beta.musicbrainz.org/report/ArtistsThatMayBeGroups" -> "ArtistsThatMayBeGroups"
     * @param {string} fullUrl The full URL of the report.
     * @returns {string} The simplified report name.
     */
    function getReportName(fullUrl) {
        try {
            const url = new URL(fullUrl);
            const pathParts = url.pathname.split('/');
            for (let i = pathParts.length - 1; i >= 0; i--) {
                if (pathParts[i]) {
                    return pathParts[i];
                }
            }
            return url.pathname;
        } catch (e) {
            error("Error parsing URL to get report name:", fullUrl, e);
            return fullUrl;
        }
    }

    /**
     * Parses the "Generated on" timestamp string from report HTML.
     * Example: "Generated on 2025-05-25 02:20 GMT+2"
     * @param {string} htmlContent The HTML content of the report page.
     * @returns {number|null} UTC milliseconds timestamp, or null if not found/parsed.
     */
    function parseGeneratedOnTimestamp(htmlContent) {
        const match = htmlContent.match(/Generated on (\d{4}-\d{2}-\d{2} \d{2}:\d{2} GMT[+-]\d{1,2})/);
        if (match && match[1]) {
            try {
                const dateString = match[1].replace(/GMT([+-]\d{1,2})/, '$1:00');
                const date = new Date(dateString);
                return date.getTime();
            } catch (e) {
                error("Error parsing generated timestamp:", match[1], e);
            }
        }
        return null;
    }

    /**
     * Extracts item count and generated timestamp from report HTML.
     * @param {string} htmlContent The HTML content of the report page.
     * @returns {{itemCount: number, mbGeneratedTimestamp: number|null}}
     */
    function extractReportData(htmlContent) {
        let itemCount = 0;
        const countMatch = htmlContent.match(/Total\s+[\w\s-]+?\s+found:\s*(\d+)/i);
        if (countMatch && countMatch[1]) {
            itemCount = parseInt(countMatch[1], 10);
        } else {
            const parser = new DOMParser();
            const doc = parser.parseFromString(htmlContent, 'text/html');
            const tableBody = doc.querySelector('table.tbl tbody');
            if (tableBody && tableBody.children.length === 0) {
                itemCount = 0;
            }
        }
        const mbGeneratedTimestamp = parseGeneratedOnTimestamp(htmlContent);
        return { itemCount, mbGeneratedTimestamp };
    }

    /**
     * Fetches the content of a given URL using GM_xmlhttpRequest.
     * @param {string} url The URL to fetch.
     * @returns {Promise<string>} A promise that resolves with the response text or rejects with an error object including status and responseText.
     */
    function fetchUrlContent(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                onload: function(response) {
                    if (response.status === 200) {
                        resolve(response.responseText);
                    } else {
                        reject({ status: response.status, message: `Failed to fetch ${url}: Status ${response.status}`, responseText: response.responseText });
                    }
                },
                onerror: function(errorResponse) {
                    reject({ status: 0, message: `Error fetching ${url}: ${errorResponse.message || JSON.stringify(errorResponse)}`, responseText: errorResponse.responseText || '' });
                }
            });
        });
    }

    /**
     * Pauses execution for a given number of milliseconds.
     * @param {number} ms The number of milliseconds to wait.
     * @returns {Promise<void>} A promise that resolves after the delay.
     */
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    /**
     * Calculates the UTC timestamp for today's 00:00:00.000.
     * This is used as a boundary to determine if a cached report's generation time is 'today' or 'yesterday/earlier'.
     * @returns {number} UTC milliseconds timestamp for today at midnight.
     */
    function getTodayMidnightUTC() {
        const now = new Date();
        // Create a Date object for current day, 00:00:00.000 UTC
        return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0);
    }

    /**
     * Formats a duration in milliseconds into a human-readable string.
     * @param {number} ms The duration in milliseconds.
     * @returns {string} Human-readable duration (e.g., "5 days ago", "1 hour ago").
     */
    function formatTimeAgo(ms) {
        if (ms < 0) return 'in the future';
        const seconds = Math.floor(ms / 1000);
        const minutes = Math.floor(seconds / 60);
        const hours = Math.floor(minutes / 60);
        const days = Math.floor(hours / 24);
        const months = Math.floor(days / 30.4375);
        const years = Math.floor(days / 365.25);

        if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
        if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
        if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
        if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
        if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
        return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
    }

    /**
     * Calculates the change in item count and formats it for display.
     * @param {Object} displayData The report's data for the current filter mode.
     * @returns {string} Formatted change string (e.g., "▲ +5 (2.5%) since 3 days ago").
     */
    function getChangeIndicator(displayData) {
        if (displayData.unsupported) {
             return '<span class="report-change-indicator" style="color: red;">(Unsupported Filter)</span>';
        }
        if (!displayData.history || displayData.history.length < 1) {
            return '<span class="report-change-indicator" style="color: grey;">(No History)</span>';
        }

        const currentEntry = displayData.history[displayData.history.length - 1];
        if (currentEntry.itemCount === -1) {
             return '<span class="report-change-indicator" style="color: grey;">(Unknown Count)</span>';
        }

        let previousEntry = null;
        for (let i = displayData.history.length - 2; i >= 0; i--) {
            if (displayData.history[i].mbGeneratedTimestamp !== currentEntry.mbGeneratedTimestamp && displayData.history[i].itemCount !== -1) {
                previousEntry = displayData.history[i];
                break;
            }
        }

        if (!previousEntry) {
            return `<span class="report-change-indicator" style="color: grey;">(New: ${currentEntry.itemCount} items)</span>`;
        }

        const change = currentEntry.itemCount - previousEntry.itemCount;
        let percentageChange = null;
        if (previousEntry.itemCount !== 0) {
            percentageChange = (change / previousEntry.itemCount) * 100;
        }

        let arrow = '↔';
        let color = 'grey';
        if (change > 0) {
            arrow = '▲';
            color = 'green';
        } else if (change < 0) {
            arrow = '▼';
            color = 'red';
        }

        const changeText = `${arrow} ${change > 0 ? '+' : ''}${change}`;
        const percentageText = percentageChange !== null ? ` (${percentageChange.toFixed(1)}%)` : '';

        let periodText = '';
        if (currentEntry.mbGeneratedTimestamp && previousEntry.mbGeneratedTimestamp) {
            const timeDiff = Math.abs(currentEntry.mbGeneratedTimestamp - previousEntry.mbGeneratedTimestamp);
            periodText = ` (${formatTimeAgo(timeDiff)})`;
        } else if (currentEntry.lastFetchedTimestamp && previousEntry.lastFetchedTimestamp) {
             const timeDiff = Math.abs(currentEntry.lastFetchedTimestamp - previousEntry.lastFetchedTimestamp);
            periodText = ` (fetched ${formatTimeAgo(timeDiff)} apart)`;
        }

        return `<span class="report-change-indicator" style="color: ${color};">${changeText}${percentageText}${periodText}</span>`;
    }

    /**
     * Determines the current filter mode from the URL's 'filter' parameter.
     * @returns {string} 'all' or 'subscribed'.
     */
    function getFilterModeFromUrl() {
        const urlParams = new URLSearchParams(window.location.search);
        return urlParams.get('filter') === '1' ? 'subscribed' : 'all';
    }

    /**
     * Toggles the filter mode in the URL and reloads the page.
     */
    function toggleFilterModeAndReload() {
        const url = new URL(window.location.href);
        if (currentFilterMode === 'all') {
            url.searchParams.set('filter', '1');
            log("Toggling filter to 'subscribed' mode. Reloading page...");
        } else {
            url.searchParams.delete('filter');
            log("Toggling filter to 'all' mode. Reloading page...");
        }
        window.location.href = url.toString();
    }

    /**
     * Displays the current filter mode next to the H1 element and makes it clickable.
     */
    function displayFilterModeOnPage() {
        const h1 = document.querySelector('#content h1');
        if (h1) {
            let filterSpan = h1.querySelector('.mb-report-filter-mode-indicator');
            if (!filterSpan) {
                filterSpan = document.createElement('span');
                filterSpan.classList.add('mb-report-filter-mode-indicator');
                Object.assign(filterSpan.style, {
                    fontSize: '0.8em',
                    fontWeight: 'normal',
                    marginLeft: '10px',
                    color: '#555',
                    cursor: 'pointer',
                    textDecoration: 'underline'
                });
                filterSpan.setAttribute('tabindex', '0');
                filterSpan.setAttribute('role', 'button');

                filterSpan.addEventListener('keydown', (event) => {
                    if (event.key === 'Enter' || event.key === ' ') {
                        event.preventDefault();
                        toggleFilterModeAndReload();
                    }
                });

                filterSpan.addEventListener('click', toggleFilterModeAndReload);
                h1.appendChild(filterSpan);
            }
            filterSpan.textContent = `(Showing: ${currentFilterMode.charAt(0).toUpperCase() + currentFilterMode.slice(1)})`;
        }
    }


    /**
     * Main execution function to scan, manage cache, and process reports.
     */
    async function init() {
        createProgressBar();

        currentFilterMode = getFilterModeFromUrl();
        log(`Current filter mode: ${currentFilterMode}.`);

        const currentReportLinks = Array.from(document.querySelectorAll('#content ul li a[href*="/report/"]'));
        if (currentReportLinks.length === 0) {
            log('No report links found on this page.');
            hideProgressBar();
            return;
        }

        let parsedCache = {};
        let newReportCache = {};
        let currentCacheVersion = null;
        let currentScriptVersionInCache = null;
        let forceAllFetchesDueToStructureChange = false;

        try {
            const cachedData = localStorage.getItem(CENTRAL_CACHE_KEY);
            if (cachedData) {
                parsedCache = JSON.parse(cachedData);
                currentCacheVersion = parsedCache.cache_version;
                currentScriptVersionInCache = parsedCache.script_version;
                log(`Cache loaded (Cache version ${currentCacheVersion || 'none'}, Script version ${currentScriptVersionInCache || 'none'}).`);

                if (currentCacheVersion === CURRENT_CACHE_VERSION) {
                    newReportCache = parsedCache.reports || {};
                } else {
                    log(`Cache mismatch (version ${CURRENT_CACHE_VERSION} vs version ${currentCacheVersion || 'none'}). Initiating full migration and refresh.`);
                    newReportCache = {};

                    // --- Migration logic to populate newReportCache from previous centralized versions ---
                    // Migrate from version 1.5 to 2.0 (nesting under 'all')
                    if (parsedCache.reports && currentCacheVersion === '1.5') {
                        log(`Migrating cache from version ${currentCacheVersion} to version ${CURRENT_CACHE_VERSION} (nesting under 'all').`);
                        for (const reportName in parsedCache.reports) {
                            newReportCache[reportName] = {
                                all: parsedCache.reports[reportName]
                            };
                        }
                    }

                    forceAllFetchesDueToStructureChange = true;
                }
            } else {
                log("No centralized cache found. All reports will be fetched.");
                newReportCache = {};
                forceAllFetchesDueToStructureChange = true;
            }
        } catch (e) {
            error("Cache error. Fetching all reports as fallback:", e);
            newReportCache = {};
            forceAllFetchesDueToStructureChange = true;
        }

        const linksToFetch = [];

        const todayMidnightUTC = getTodayMidnightUTC();

        // Phase 1: Identify reports that need fetching or hiding based on cache
        for (const link of currentReportLinks) {
            const reportName = getReportName(link.href);
            let fullReportUrl = link.href;

            const originalUrlObj = new URL(fullReportUrl);
            if (currentFilterMode === 'subscribed') {
                originalUrlObj.searchParams.set('filter', '1');
                fullReportUrl = originalUrlObj.toString();
                link.href = fullReportUrl;
            } else {
                if (originalUrlObj.searchParams.has('filter')) {
                    originalUrlObj.searchParams.delete('filter');
                    fullReportUrl = originalUrlObj.toString();
                    link.href = fullReportUrl;
                }
            }

            const cachedReportEntry = newReportCache[reportName]?.[currentFilterMode];
            const parentLi = link.closest('li');

            let needsFetch = false;
            let debugReason = "No cache entry";

            if (cachedReportEntry && cachedReportEntry.unsupported) {
                needsFetch = false;
                debugReason = "Filter explicitly marked as unsupported.";
                log(`Skipping fetch for ${reportName} (${currentFilterMode} mode). Reason: ${debugReason}`);
            } else if (forceAllFetchesDueToStructureChange) {
                needsFetch = true;
                debugReason = `Cache version updated (forced full refresh for ${currentFilterMode} mode).`;
            } else {
                if (!cachedReportEntry || !cachedReportEntry.lastFetchedTimestamp || (Date.now() - cachedReportEntry.lastFetchedTimestamp >= INTERNAL_CACHE_DURATION)) {
                    let latestMbGeneratedTimestamp = null;
                    if (cachedReportEntry && cachedReportEntry.history && cachedReportEntry.history.length > 0) {
                        latestMbGeneratedTimestamp = cachedReportEntry.history[cachedReportEntry.history.length - 1].mbGeneratedTimestamp;
                    }

                    if (!latestMbGeneratedTimestamp || latestMbGeneratedTimestamp < todayMidnightUTC) {
                        needsFetch = true;
                        debugReason = latestMbGeneratedTimestamp ? "Data older than today's 00:00 UTC." : `New data for ${currentFilterMode} mode (or no MB timestamp in cache).`;
                    } else {
                        debugReason = `MB data for ${currentFilterMode} mode is already cached for today.`;
                    }
                } else {
                    debugReason = `Recently fetched in this session for ${currentFilterMode} mode.`;
                }
            }

            if (needsFetch) {
                linksToFetch.push({ link, parentLi, fullReportUrl, reportName });
                log(`Preparing to fetch ${reportName} (${currentFilterMode} mode). Reason: ${debugReason}`);
            }

            if (parentLi) {
                const displayData = newReportCache[reportName]?.[currentFilterMode] || { history: [] };
                const latestItemCount = displayData.history && displayData.history.length > 0 ?
                                        displayData.history[displayData.history.length - 1].itemCount : -1;

                if (displayData.unsupported && currentFilterMode === 'subscribed') {
                    parentLi.style.display = 'none';
                    log(`Hidden: ${reportName} (filter not supported).`);
                } else if (latestItemCount === 0) {
                    parentLi.style.display = 'none';
                    log(`Hidden: ${reportName} (0 items).`);
                } else {
                    parentLi.style.display = '';
                    if (latestItemCount === -1) {
                         log(`Visible: ${reportName} (item count unknown).`);
                    } else {
                         log(`Visible: ${reportName} (${latestItemCount} items).`);
                    }
                }

                const existingIndicator = parentLi.querySelector('.report-change-indicator');
                if (existingIndicator) {
                    existingIndicator.remove();
                }

                const changeIndicatorHtml = getChangeIndicator(displayData);
                const indicatorSpan = document.createElement('span');
                indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
                link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
            }
        }

        totalLinksToFetch = linksToFetch.length;

        if (totalLinksToFetch === 0) {
            log('All reports for current mode cached. No fetches needed.');
            hideProgressBar();
            localStorage.setItem(CENTRAL_CACHE_KEY, JSON.stringify({
                script_version: currentScriptVersion,
                cache_version: CURRENT_CACHE_VERSION,
                reports: newReportCache
            }));
            displayFilterModeOnPage();
            return;
        }

        showProgressBar();

        // Phase 2: Fetch and process reports that need updating
        for (const { link, parentLi, fullReportUrl, reportName } of linksToFetch) {
            try {
                log(`Fetching ${reportName} (URL: ${fullReportUrl})...`);
                const htmlContent = await fetchUrlContent(fullReportUrl);
                const { itemCount, mbGeneratedTimestamp } = extractReportData(htmlContent);

                if (!newReportCache[reportName]) {
                    newReportCache[reportName] = {};
                }
                if (!newReportCache[reportName][currentFilterMode]) {
                    newReportCache[reportName][currentFilterMode] = { history: [] };
                }
                newReportCache[reportName][currentFilterMode].unsupported = false;

                let currentReportEntry = newReportCache[reportName][currentFilterMode];

                if (mbGeneratedTimestamp !== null) {
                    const lastHistoryEntry = currentReportEntry.history[currentReportEntry.history.length - 1];
                    if (!lastHistoryEntry || lastHistoryEntry.mbGeneratedTimestamp !== mbGeneratedTimestamp) {
                        currentReportEntry.history.push({ mbGeneratedTimestamp, itemCount });
                    } else {
                        lastHistoryEntry.itemCount = itemCount;
                    }
                    currentReportEntry.history = currentReportEntry.history.slice(Math.max(currentReportEntry.history.length - HISTORY_MAX_DAYS, 0));
                }

                currentReportEntry.lastFetchedTimestamp = Date.now();
                newReportCache[reportName][currentFilterMode] = currentReportEntry;

                if (itemCount === 0) {
                    if (parentLi) parentLi.style.display = 'none';
                    log(`Fetched & hidden: ${reportName} (${itemCount} items, ${currentFilterMode} mode).`);
                } else {
                    if (parentLi) parentLi.style.display = '';
                    log(`Fetched & visible: ${reportName} (${itemCount} items, ${currentFilterMode} mode).`);
                }

                if (parentLi) {
                    const existingIndicator = parentLi.querySelector('.report-change-indicator');
                    if (existingIndicator) {
                        existingIndicator.remove();
                    }
                    const changeIndicatorHtml = getChangeIndicator(currentReportEntry);
                    const indicatorSpan = document.createElement('span');
                    indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
                    link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
                }

            } catch (e) {
                const isUnsupportedFilterError = e.status === 500 &&
                                                 currentFilterMode === 'subscribed' &&
                                                 e.responseText &&
                                                 e.responseText.includes("This report does not support filtering");

                if (isUnsupportedFilterError) {
                    log(`Filter unsupported for ${reportName}. Hiding.`);
                    if (!newReportCache[reportName]) {
                        newReportCache[reportName] = {};
                    }
                    if (!newReportCache[reportName][currentFilterMode]) {
                        newReportCache[reportName][currentFilterMode] = { history: [] };
                    }
                    newReportCache[reportName][currentFilterMode].unsupported = true;
                    newReportCache[reportName][currentFilterMode].lastFetchedTimestamp = Date.now();

                    if (parentLi) {
                        parentLi.style.display = 'none';
                        const existingIndicator = parentLi.querySelector('.report-change-indicator');
                        if (existingIndicator) {
                            existingIndicator.remove();
                        }
                        const indicatorSpan = document.createElement('span');
                        indicatorSpan.innerHTML = ` <span class="report-change-indicator" style="color: red;">(Unsupported Filter)</span>`;
                        link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
                    }
                } else {
                    error(`Failed processing ${reportName} (${currentFilterMode} mode). Status: ${e.status || 'N/A'}. Message: ${e.message || e}`);
                }
            } finally {
                fetchedLinksCount++;
                updateProgressBar();
                if (fetchedLinksCount < totalLinksToFetch) {
                    await sleep(REQUEST_DELAY);
                }
            }
        }

        try {
            localStorage.setItem(CENTRAL_CACHE_KEY, JSON.stringify({
                script_version: currentScriptVersion,
                cache_version: CURRENT_CACHE_VERSION,
                reports: newReportCache
            }));
            log("Cache updated in localStorage.");
        } catch (e) {
            error("Error saving cache to localStorage:", e);
        }

        progressBar.style.width = '100%';
        setTimeout(() => {
            hideProgressBar();
            displayFilterModeOnPage();
        }, 500);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址