您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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或关注我们的公众号极客氢云获取最新地址