Hides report links on MusicBrainz if the report contains no items, with streamlined cache migration, advanced caching, progress bar, and enhanced logging. Also indicates report changes since the last recorded data.
当前为
// ==UserScript==
// @name MusicBrainz: Reports Statistics
// @namespace https://musicbrainz.org/user/chaban
// @version 1.0.19
// @description Hides report links on MusicBrainz if the report contains no items, with streamlined cache migration, advanced caching, progress bar, and enhanced logging. Also indicates report changes since the last recorded data.
// @tag ai-created
// @author chaban
// @license MIT
// @match *://*.musicbrainz.org/reports
// @grant GM_xmlhttpRequest
// @grant GM_info
// ==/UserScript==
(function() {
'use strict';
const currentScriptVersion = GM_info.script.version; // Get script version from metadata
const CURRENT_CACHE_VERSION = '1.5'; // Reverted to 1.5 as it represents the current stable data structure
const INTERNAL_CACHE_DURATION = 1 * 60 * 60 * 1000; // 1 hour for in-session throttling
const REQUEST_DELAY = 1000; // Delay between requests in milliseconds (1 request per second)
const HISTORY_MAX_DAYS = 30; // Max number of historical data points to store
// MusicBrainz report generation time in UTC
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;
/**
* 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://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) {
console.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 {
// Replace GMT+Z with Z (ISO 8601 format for UTC offset)
const dateString = match[1].replace(/GMT([+-]\d{1,2})/, '$1:00');
// Create a Date object from the string, which should correctly parse with offset
const date = new Date(dateString);
return date.getTime(); // Return UTC milliseconds
} catch (e) {
console.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 {
// Fallback check if table tbody is empty, assuming 0 items
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.
*/
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 {
console.error(`Failed to fetch ${url}: Status ${response.status}`);
reject(new Error(`Failed to fetch ${url}: Status ${response.status}`));
}
},
onerror: function(error) {
console.error(`Error fetching ${url}:`, error);
reject(error);
}
});
});
}
/**
* 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'; // Should not happen for 'ago'
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); // Average days in a month
const years = Math.floor(days / 365.25); // Average days in a year
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 {Array<Object>} history The report's history array.
* @returns {string} Formatted change string (e.g., "▲ +5 (2.5%) since 3 days ago").
*/
function getChangeIndicator(history) {
if (!history || history.length < 1) {
return '<span class="report-change-indicator" style="color: grey;">(No History)</span>';
}
const currentEntry = history[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 = history.length - 2; i >= 0; i--) {
if (history[i].mbGeneratedTimestamp !== currentEntry.mbGeneratedTimestamp && history[i].itemCount !== -1) {
previousEntry = history[i];
break;
}
}
if (!previousEntry) {
return '<span class="report-change-indicator" style="color: grey;">(New)</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>`;
}
/**
* Main execution function to scan, manage cache, and process reports.
*/
async function init() {
createProgressBar();
const currentReportLinks = Array.from(document.querySelectorAll('#content ul li a[href*="/report/"]'));
if (currentReportLinks.length === 0) {
console.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;
console.log(`Centralized cache loaded. Cache Version: ${currentCacheVersion || 'none'}, Script Version: ${currentScriptVersionInCache || 'none'}`);
if (currentCacheVersion === CURRENT_CACHE_VERSION) {
newReportCache = parsedCache.reports || {};
} else {
console.log(`Cache version mismatch. Current: ${CURRENT_CACHE_VERSION}, Cached: ${currentCacheVersion || 'none'}. Initiating full refresh.`);
newReportCache = {}; // Start with an empty cache for the new structure
// Only migrate if the cached version is exactly the previous "stable" structure version (1.5).
// For any other old version or unversioned cache, a full refresh will occur.
if (currentCacheVersion === '1.5' && parsedCache.reports) {
console.log(`Migrating central cache from version ${currentCacheVersion}.`);
for (const reportName in parsedCache.reports) {
newReportCache[reportName] = parsedCache.reports[reportName]; // Copy as is, structure compatible
}
}
forceAllFetchesDueToStructureChange = true;
}
} else {
console.log("No centralized cache found. All reports will be fetched.");
newReportCache = {}; // Ensure it's empty
forceAllFetchesDueToStructureChange = true;
}
} catch (e) {
console.error("Error loading or parsing centralized cache. All reports will be fetched as fallback:", e);
newReportCache = {}; // Ensure it's empty
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 fullReportUrl = link.href;
const reportName = getReportName(fullReportUrl);
const cachedEntry = newReportCache[reportName]; // Use newReportCache which contains migrated data
const parentLi = link.closest('li');
let needsFetch = false;
let debugReason = "No cache entry";
if (forceAllFetchesDueToStructureChange) {
needsFetch = true;
debugReason = "Cache version updated (forced full refresh)";
} else {
if (!cachedEntry || !cachedEntry.lastFetchedTimestamp || (Date.now() - cachedEntry.lastFetchedTimestamp >= INTERNAL_CACHE_DURATION)) {
let latestMbGeneratedTimestamp = null;
if (cachedEntry && cachedEntry.history && cachedEntry.history.length > 0) {
latestMbGeneratedTimestamp = cachedEntry.history[cachedEntry.history.length - 1].mbGeneratedTimestamp;
}
if (!latestMbGeneratedTimestamp || latestMbGeneratedTimestamp < todayMidnightUTC) {
needsFetch = true;
debugReason = latestMbGeneratedTimestamp ? "Data older than today's 00:00 UTC" : "No MB generated timestamp in cache";
} else {
debugReason = "MB data for today is already cached";
}
} else {
debugReason = "Recently fetched in this session";
}
}
if (needsFetch) {
linksToFetch.push({ link, parentLi, fullReportUrl, reportName });
console.log(`[Fetch Needed] ${reportName} (Reason: ${debugReason})`);
}
// Always add change indicator for current report, using the data currently in newReportCache
if (parentLi) {
const currentReportDataForDisplay = newReportCache[reportName] || { history: [] };
const latestItemCount = currentReportDataForDisplay.history && currentReportDataForDisplay.history.length > 0 ?
currentReportDataForDisplay.history[currentReportDataForDisplay.history.length - 1].itemCount : -1;
if (latestItemCount === 0) {
parentLi.style.display = 'none';
console.log(`[Display Update] Hidden: ${reportName} (Items: ${latestItemCount})`);
} else if (latestItemCount > 0) {
parentLi.style.display = '';
console.log(`[Display Update] Kept: ${reportName} (Items: ${latestItemCount})`);
} else {
parentLi.style.display = '';
console.log(`[Display Update] Kept: ${reportName} (Items: unknown)`);
}
const changeIndicatorHtml = getChangeIndicator(currentReportDataForDisplay.history);
const indicatorSpan = document.createElement('span');
indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
}
}
totalLinksToFetch = linksToFetch.length;
if (totalLinksToFetch === 0) {
console.log('All report statuses are validly cached. No new fetches needed.');
hideProgressBar();
localStorage.setItem(CENTRAL_CACHE_KEY, JSON.stringify({
script_version: currentScriptVersion,
cache_version: CURRENT_CACHE_VERSION,
reports: newReportCache
}));
return;
}
showProgressBar();
// Phase 2: Fetch and process reports that need updating
for (const { link, parentLi, fullReportUrl, reportName } of linksToFetch) {
try {
console.log(`[Fetching] ${reportName}...`);
const htmlContent = await fetchUrlContent(fullReportUrl);
const { itemCount, mbGeneratedTimestamp } = extractReportData(htmlContent);
let currentReportEntry = newReportCache[reportName] || { history: [] };
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] = currentReportEntry;
if (itemCount === 0) {
if (parentLi) parentLi.style.display = 'none';
console.log(`[Fetched] Hidden: ${reportName} (Items: ${itemCount})`);
} else {
if (parentLi) parentLi.style.display = '';
console.log(`[Fetched] Kept: ${reportName} (Items: ${itemCount})`);
}
if (parentLi) {
const existingIndicator = parentLi.querySelector('.report-change-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
const changeIndicatorHtml = getChangeIndicator(currentReportEntry.history);
const indicatorSpan = document.createElement('span');
indicatorSpan.innerHTML = ` ${changeIndicatorHtml}`;
link.parentNode.insertBefore(indicatorSpan, link.nextSibling);
}
} catch (error) {
console.error(`[Error] Could not process report ${reportName}:`, error);
} 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
}));
console.log("Reports cache updated and versioned in localStorage.");
} catch (e) {
console.error("Error saving reports cache to localStorage:", e);
}
progressBar.style.width = '100%';
setTimeout(hideProgressBar, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();