您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match
当前为
// ==UserScript== // @name MusicBrainz: Add search link for barcode // @namespace https://musicbrainz.org/user/chaban // @description Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match // @version 2.0 // @tag ai-created // @author chaban // @license MIT // @include https://*musicbrainz.org/edit/* // @include https://*musicbrainz.org/search/edits* // @include https://*musicbrainz.org/*/*/edits // @include https://*musicbrainz.org/*/*/open_edits // @include https://*musicbrainz.org/user/*/edits* // @grant GM_xmlhttpRequest // @grant GM_info // ==/UserScript== (function() { 'use strict'; const barcodeRegex = /(\b\d{8,14}\b)/g; const targetSelector = '.add-release'; const API_BASE_URL = 'https://musicbrainz.org/ws/2/release/'; const MAX_RETRIES = 5; // Global state for dynamic rate limiting based on API response headers let lastRequestFinishedTime = 0; // Timestamp of when the last request successfully finished (or failed) let nextAvailableRequestTime = 0; // Earliest time the next request can be made, considering API hints // Store a mapping of barcode to their corresponding span elements const barcodeToSpansMap = new Map(); // Map<string, HTMLElement[]> const uniqueBarcodes = new Set(); // Set<string> // Define a short application name for the User-Agent string with a prefix const SHORT_APP_NAME = 'UserJS.BarcodeLink'; // Helper function for delay function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Helper to parse response headers string into a simple object function parseHeaders(headerStr) { const headers = {}; if (!headerStr) return headers; headerStr.split('\n').forEach(line => { const parts = line.split(':'); if (parts.length > 1) { const key = parts[0].trim().toLowerCase(); const value = parts.slice(1).join(':').trim(); headers[key] = value; } }); return headers; } // Function to fetch data from MusicBrainz API with dynamic rate limiting based on headers async function fetchBarcodeData(query) { // Dynamically get script version from GM_info, use custom short app name const USER_AGENT = `${SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`; for (let i = 0; i < MAX_RETRIES; i++) { const now = Date.now(); let waitTime = 0; if (now < nextAvailableRequestTime) { waitTime = nextAvailableRequestTime - now; } else { const timeSinceLastRequest = now - lastRequestFinishedTime; if (timeSinceLastRequest < 1000) { waitTime = 1000 - timeSinceLastRequest; } } if (waitTime > 0) { console.log(`[${GM_info.script.name}] Waiting for ${waitTime}ms before sending request for query: ${query.substring(0, 50)}...`); await delay(waitTime); } try { return await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${API_BASE_URL}?query=${encodeURIComponent(query)}&fmt=json`, headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, onload: function(response) { lastRequestFinishedTime = Date.now(); // Mark end of this request attempt const headers = parseHeaders(response.responseHeaders); const rateLimitReset = parseInt(headers['x-ratelimit-reset'], 10) * 1000; // Convert to ms epoch const rateLimitRemaining = parseInt(headers['x-ratelimit-remaining'], 10); const retryAfterSeconds = parseInt(headers['retry-after'], 10); const rateLimitZone = headers['x-ratelimit-zone']; // Update nextAvailableRequestTime based on response headers if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { nextAvailableRequestTime = lastRequestFinishedTime + (retryAfterSeconds * 1000); console.warn(`[${GM_info.script.name}] Server requested Retry-After: ${retryAfterSeconds}s. Next request delayed until ${new Date(nextAvailableRequestTime).toLocaleTimeString()}.`); } else if (!isNaN(rateLimitReset) && rateLimitRemaining === 0) { nextAvailableRequestTime = rateLimitReset; console.warn(`[${GM_info.script.name}] Rate limit exhausted for zone "${rateLimitZone}". Next request delayed until ${new Date(nextAvailableRequestTime).toLocaleTimeString()}.`); } else if (response.status === 503) { nextAvailableRequestTime = lastRequestFinishedTime + 5000; console.warn(`[${GM_info.script.name}] 503 Service Unavailable for query ${query.substring(0, 50)}.... Defaulting to 5s delay.`); } else { nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 1000); } if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); resolve(data); } catch (e) { console.error(`[${GM_info.script.name}] Error parsing JSON for query ${query.substring(0, 50)}...:`, e); reject(new Error(`JSON parsing error for query ${query.substring(0, 50)}...`)); } } else if (response.status === 503) { reject(new Error('Rate limit hit or server overloaded')); } else { console.error(`[${GM_info.script.name}] API request for query ${query.substring(0, 50)}... failed with status ${response.status}: ${response.statusText}`); reject(new Error(`API error ${response.status} for query ${query.substring(0, 50)}...`)); } }, onerror: function(error) { lastRequestFinishedTime = Date.now(); nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 5000); console.error(`[${GM_info.script.name}] Network error for query ${query.substring(0, 50)}...:`, error); reject(new Error(`Network error for query ${query.substring(0, 50)}...`)); }, ontimeout: function() { lastRequestFinishedTime = Date.now(); nextAvailableRequestTime = Math.max(nextAvailableRequestTime, lastRequestFinishedTime + 5000); console.warn(`[${GM_info.script.name}] Request for query ${query.substring(0, 50)}... timed out.`); reject(new Error(`Timeout for query ${query.substring(0, 50)}...`)); } }); }); } catch (error) { if (i < MAX_RETRIES - 1 && (error.message.includes('Rate limit hit') || error.message.includes('Network error') || error.message.includes('Timeout'))) { console.warn(`[${GM_info.script.name}] Retrying query ${query.substring(0, 50)}... (attempt ${i + 1}/${MAX_RETRIES}). Error: ${error.message}`); } else { throw error; } } } } // Function to find barcodes and store their associated span elements function collectBarcodesAndCreateSpans(node) { if (node.nodeType === Node.TEXT_NODE) { const originalText = node.textContent; const matches = [...originalText.matchAll(barcodeRegex)]; if (matches.length === 0) return; let lastIndex = 0; const fragment = document.createDocumentFragment(); for (const match of matches) { const barcode = match[0]; const startIndex = match.index; const endIndex = startIndex + barcode.length; if (startIndex > lastIndex) { fragment.appendChild(document.createTextNode(originalText.substring(lastIndex, startIndex))); } const barcodeSpan = document.createElement('span'); barcodeSpan.textContent = barcode; // Only barcode text initially // Store reference to the span element if (!barcodeToSpansMap.has(barcode)) { barcodeToSpansMap.set(barcode, []); } barcodeToSpansMap.get(barcode).push(barcodeSpan); uniqueBarcodes.add(barcode); // Add to unique set fragment.appendChild(barcodeSpan); lastIndex = endIndex; } if (lastIndex < originalText.length) { fragment.appendChild(document.createTextNode(originalText.substring(lastIndex))); } if (fragment.hasChildNodes()) { node.parentNode.insertBefore(fragment, node); node.remove(); } } else if (node.nodeType === Node.ELEMENT_NODE) { if (node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') { const children = Array.from(node.childNodes); for (const child of children) { collectBarcodesAndCreateSpans(child); } } } } async function processAddReleaseTables() { const tables = document.querySelectorAll(targetSelector); // First pass: Collect all unique barcodes and create initial spans tables.forEach(table => { table.querySelectorAll('td').forEach(cell => { collectBarcodesAndCreateSpans(cell); }); }); if (uniqueBarcodes.size === 0) { console.log(`[${GM_info.script.name}] No barcodes found to process.`); return; } // Construct the combined Lucene query const combinedQuery = Array.from(uniqueBarcodes).map(b => `barcode:${b}`).join(' OR '); try { const data = await fetchBarcodeData(combinedQuery); if (data && data.releases) { // Group releases by barcode for easier processing const releasesByBarcode = new Map(); // Map<string, any[]> data.releases.forEach(release => { if (release.barcode) { if (!releasesByBarcode.has(release.barcode)) { releasesByBarcode.set(release.barcode, []); } releasesByBarcode.get(release.barcode).push(release); } }); // Process each unique barcode based on the batched results uniqueBarcodes.forEach(barcode => { const spans = barcodeToSpansMap.get(barcode); const releasesForBarcode = releasesByBarcode.get(barcode) || []; // This will be empty if no releases for this barcode // Link and highlight ONLY if there are multiple releases for this specific barcode if (spans && releasesForBarcode.length > 1) { const searchUrl = `//musicbrainz.org/search?type=release&method=advanced&query=barcode:${barcode}`; const searchLink = document.createElement('a'); searchLink.href = searchUrl; searchLink.setAttribute('target', '_blank'); searchLink.textContent = 'Search'; spans.forEach(barcodeSpan => { // Append link barcodeSpan.appendChild(document.createTextNode(' (')); barcodeSpan.appendChild(searchLink.cloneNode(true)); // Clone to avoid moving element if same barcode appears multiple times barcodeSpan.appendChild(document.createTextNode(')')); // Apply highlighting barcodeSpan.style.backgroundColor = 'yellow'; barcodeSpan.title = `Multiple MusicBrainz releases found for barcode: ${barcode}`; }); } }); } else { console.warn(`[${GM_info.script.name}] No releases found for any barcodes in the batch query.`); } } catch (error) { console.error(`[${GM_info.script.name}] Failed to fetch data for all barcodes: ${error.message}`); } } // Start the process processAddReleaseTables(); })();