您需要先安装一个扩展,例如 篡改猴、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 3.1 // @tag ai-created // @author chaban // @license MIT // @match *://*.musicbrainz.org/edit/* // @match *://*.musicbrainz.org/search/edits* // @match *://*.musicbrainz.org/*/*/edits // @match *://*.musicbrainz.org/*/*/open_edits // @match *://*.musicbrainz.org/user/*/edits* // @connect musicbrainz.org // @icon https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png // @grant GM_xmlhttpRequest // @grant GM.info // ==/UserScript== (function() { 'use strict'; /** * Configuration object to centralize all constants and settings. */ const Config = { BARCODE_REGEX: /(\b\d{8,14}\b)/g, TARGET_SELECTOR: '.add-release', API_BASE_URL: 'https://musicbrainz.org/ws/2/release/', MAX_RETRIES: 5, SHORT_APP_NAME: 'UserJS.BarcodeLink', USER_AGENT: '', SEARCH_LINK_CLASS: 'mb-barcode-search-link', PROCESSED_BARCODE_SPAN_CLASS: 'mb-barcode-processed', }; /** * Utility functions. */ const Utils = { /** * Pauses execution for a given number of milliseconds. * @param {number} ms - The number of milliseconds to sleep. * @returns {Promise<void>} A promise that resolves after the specified delay. */ delay: function(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }, /** * Parses raw response headers string into a simple object. * @param {string} headerStr - The raw headers string. * @returns {Object} An object mapping header names to their values. */ parseHeaders: function(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; } }; /** * Handles all interactions with the MusicBrainz API, including rate limiting, retries, and pagination. */ const MusicBrainzAPI = { _lastRequestFinishedTime: 0, _nextAvailableRequestTime: 0, /** * Sends a single GM_xmlhttpRequest to the MusicBrainz API. * Handles response parsing and updates global rate limiting state. * @param {string} url - The full URL for the API request. * @returns {Promise<Object>} - Resolves with parsed JSON data, rejects on error or malformed response. */ _sendHttpRequest: function(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'User-Agent': Config.USER_AGENT, 'Accept': 'application/json' }, onload: (res) => { this._lastRequestFinishedTime = Date.now(); const headers = Utils.parseHeaders(res.responseHeaders); const rateLimitReset = parseInt(headers['x-ratelimit-reset'], 10) * 1000; const rateLimitRemaining = parseInt(headers['x-ratelimit-remaining'], 10); const retryAfterSeconds = parseInt(headers['retry-after'], 10); const rateLimitZone = headers['x-ratelimit-zone']; if (!isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { this._nextAvailableRequestTime = this._lastRequestFinishedTime + (retryAfterSeconds * 1000); console.warn(`[${GM.info.script.name}] Server requested Retry-After: ${retryAfterSeconds}s. Next request delayed until ${new Date(this._nextAvailableRequestTime).toLocaleTimeString()}.`); } else if (!isNaN(rateLimitReset) && rateLimitRemaining === 0) { this._nextAvailableRequestTime = rateLimitReset; console.warn(`[${GM.info.script.name}] Rate limit exhausted for zone "${rateLimitZone}". Next request delayed until ${new Date(this._nextAvailableRequestTime).toLocaleTimeString()}.`); } else if (res.status === 503) { this._nextAvailableRequestTime = this._lastRequestFinishedTime + 5000; console.warn(`[${GM.info.script.name}] 503 Service Unavailable. Defaulting to 5s delay.`); } else { this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 1000); } if (res.status >= 200 && res.status < 300) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (e) { console.error(`[${GM.info.script.name}] Error parsing JSON for URL ${url.substring(0, 100)}...:`, e); reject(new Error(`JSON parsing error for URL ${url.substring(0, 100)}...`)); } } else if (res.status === 503) { reject(new Error('Rate limit hit or server overloaded')); } else { console.error(`[${GM.info.script.name}] API request for URL ${url.substring(0, 100)}... failed with status ${res.status}: ${res.statusText}`); reject(new Error(`API error ${res.status} for URL ${url.substring(0, 100)}...`)); } }, onerror: (error) => { this._lastRequestFinishedTime = Date.now(); this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 5000); console.error(`[${GM.info.script.name}] Network error for URL ${url.substring(0, 100)}...:`, error); reject(new Error(`Network error for URL ${url.substring(0, 100)}...`)); }, ontimeout: () => { this._lastRequestFinishedTime = Date.now(); this._nextAvailableRequestTime = Math.max(this._nextAvailableRequestTime, this._lastRequestFinishedTime + 5000); console.warn(`[${GM.info.script.name}] Request for URL ${url.substring(0, 100)}... timed out.`); reject(new Error(`Timeout for URL ${url.substring(0, 100)}...`)); } }); }); }, /** * Executes an API call with retry logic and rate limiting delays. * @param {string} url - The URL for the API request. * @param {string} logContext - A string to append to log messages (e.g., "query: X, offset: Y"). * @returns {Promise<Object>} - Resolves with parsed JSON data, rejects if all retries fail. */ _executeApiCallWithRetries: async function(url, logContext) { for (let i = 0; i < Config.MAX_RETRIES; i++) { const now = Date.now(); let waitTime = 0; if (now < this._nextAvailableRequestTime) { waitTime = this._nextAvailableRequestTime - now; } else { const timeSinceLastRequest = now - this._lastRequestFinishedTime; if (timeSinceLastRequest < 1000) { waitTime = 1000 - timeSinceLastRequest; } } if (waitTime > 0) { console.log(`[${GM.info.script.name}] Waiting for ${waitTime}ms before sending request (${logContext}).`); await Utils.delay(waitTime); } try { return await this._sendHttpRequest(url); } catch (error) { if (i < Config.MAX_RETRIES - 1 && (error.message.includes('Rate limit hit') || error.message.includes('Network error') || error.message.includes('Timeout') || error.message.includes('server overloaded'))) { console.warn(`[${GM.info.script.name}] Retrying request (${logContext}) (attempt ${i + 1}/${Config.MAX_RETRIES}). Error: ${error.message}`); } else { throw error; } } } throw new Error(`[${GM.info.script.name}] Failed to complete request after ${Config.MAX_RETRIES} attempts (${logContext}).`); }, /** * Fetches data from MusicBrainz API with dynamic rate limiting and pagination. * @param {string} query - The search query for barcodes. * @returns {Promise<{releases: Array, count: number}>} - Resolves with an object containing all fetched releases and their count. */ fetchBarcodeData: async function(query) { const BASE_SEARCH_URL = `${Config.API_BASE_URL}?fmt=json`; let allReleases = []; let currentOffset = 0; const limit = 100; let totalCount = 0; do { const url = `${BASE_SEARCH_URL}&query=${encodeURIComponent(query)}&limit=${limit}&offset=${currentOffset}`; const logContext = `query: ${query.substring(0, 50)}..., offset: ${currentOffset}`; let responseData; let fetchedAnyReleasesOnCurrentPage = false; try { responseData = await this._executeApiCallWithRetries(url, logContext); } catch (error) { console.error(`[${GM.info.script.name}] Failed to fetch page for query ${query.substring(0, 50)}... (offset: ${currentOffset}): ${error.message}`); return { releases: allReleases, count: allReleases.length }; } if (responseData && Array.isArray(responseData.releases)) { if (responseData.releases.length > 0) { allReleases = allReleases.concat(responseData.releases); fetchedAnyReleasesOnCurrentPage = true; } if (totalCount === 0) { totalCount = responseData.count; if (totalCount === 0 && responseData.releases.length === 0) { console.log(`[${GM.info.script.name}] No releases found for query ${query.substring(0, 50)}... (initial count 0).`); break; } } currentOffset += responseData.releases.length; if (responseData.releases.length < limit) { console.log(`[${GM.info.script.name}] Last page fetched for query ${query.substring(0, 50)}... (returned ${responseData.releases.length} releases). Terminating pagination.`); break; } if (!fetchedAnyReleasesOnCurrentPage && currentOffset < totalCount) { console.warn(`[${GM.info.script.name}] Expected more releases but received none for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Terminating pagination.`); break; } } else { console.warn(`[${GM.info.script.name}] Malformed response or no releases array for query ${query.substring(0, 50)}... (offset: ${currentOffset}). Assuming no more data from this point.`); break; } if (totalCount > 0 && currentOffset >= totalCount) { console.log(`[${GM.info.script.name}] All ${totalCount} releases fetched for query ${query.substring(0, 50)}...`); break; } } while (true); return { releases: allReleases, count: allReleases.length }; } }; /** * Scans the DOM for barcode elements and manages their associated data. */ const DOMScanner = { _barcodeToSpansMap: new Map(), _uniqueBarcodes: new Set(), /** * Finds barcodes in text nodes and wraps them in spans, storing references. * @param {Node} node - The current DOM node to process. */ collectBarcodesAndCreateSpans: function(node) { if (node.nodeType === Node.TEXT_NODE) { if (node.parentNode && node.parentNode.classList && node.parentNode.classList.contains(Config.PROCESSED_BARCODE_SPAN_CLASS)) { return; } const originalText = node.textContent; const matches = [...originalText.matchAll(Config.BARCODE_REGEX)]; 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; barcodeSpan.classList.add(Config.PROCESSED_BARCODE_SPAN_CLASS); if (!this._barcodeToSpansMap.has(barcode)) { this._barcodeToSpansMap.set(barcode, []); } this._barcodeToSpansMap.get(barcode).push(barcodeSpan); this._uniqueBarcodes.add(barcode); 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' && !node.classList.contains(Config.PROCESSED_BARCODE_SPAN_CLASS)) { const children = Array.from(node.childNodes); for (const child of children) { this.collectBarcodesAndCreateSpans(child); } } } }, /** * Returns the set of unique barcodes found. * @returns {Set<string>} A set of unique barcode strings. */ getUniqueBarcodes: function() { return this._uniqueBarcodes; }, /** * Returns the map of barcodes to their corresponding span elements. * @returns {Map<string, HTMLElement[]>} A map where keys are barcodes and values are arrays of their span elements. */ getBarcodeSpansMap: function() { return this._barcodeToSpansMap; } }; /** * Main application logic for the userscript. */ const BarcodeLinkerApp = { /** * Initializes the application. */ init: function() { Config.USER_AGENT = `${Config.SHORT_APP_NAME}/${GM.info.script.version} ( ${GM.info.script.namespace} )`; this.processAddReleaseTables(); }, /** * Processes all "Add release" tables to find barcodes, fetch data, and update the DOM. */ processAddReleaseTables: async function() { const tables = document.querySelectorAll(Config.TARGET_SELECTOR); tables.forEach(table => { table.querySelectorAll('td').forEach(cell => { DOMScanner.collectBarcodesAndCreateSpans(cell); }); }); const uniqueBarcodes = DOMScanner.getUniqueBarcodes(); if (uniqueBarcodes.size === 0) { console.log(`[${GM.info.script.name}] No barcodes found to process.`); return; } const combinedQuery = Array.from(uniqueBarcodes).map(b => `barcode:${b}`).join(' OR '); try { const data = await MusicBrainzAPI.fetchBarcodeData(combinedQuery); if (data && data.releases) { const releasesByBarcode = new Map(); data.releases.forEach(release => { if (release.barcode) { if (!releasesByBarcode.has(release.barcode)) { releasesByBarcode.set(release.barcode, []); } releasesByBarcode.get(release.barcode).push(release); } }); uniqueBarcodes.forEach(barcode => { const spans = DOMScanner.getBarcodeSpansMap().get(barcode); const releasesForBarcode = releasesByBarcode.get(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'; searchLink.classList.add(Config.SEARCH_LINK_CLASS); spans.forEach(barcodeSpan => { if (!barcodeSpan.querySelector(`.${Config.SEARCH_LINK_CLASS}`)) { barcodeSpan.appendChild(document.createTextNode(' (')); barcodeSpan.appendChild(searchLink.cloneNode(true)); barcodeSpan.appendChild(document.createTextNode(')')); barcodeSpan.style.backgroundColor = 'yellow'; barcodeSpan.title = `Multiple MusicBrainz releases found for barcode: ${barcode}`; } else { console.log(`[${GM.info.script.name}] Skipping duplicate link addition for barcode ${barcode} in span. Link already exists.`); } }); } }); } else { console.warn(`[${GM.info.script.name}] No releases found for any barcodes in the batch query, or malformed response.`); } } catch (error) { console.error(`[${GM.info.script.name}] Failed to fetch data for all barcodes: ${error.message}`); } } }; BarcodeLinkerApp.init(); })();