您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission.
// ==UserScript== // @name Click buttons across tabs // @namespace https://musicbrainz.org/user/chaban // @version 4.1.1 // @tag ai-created // @description Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission. // @author chaban // @license MIT // @match *://*.musicbrainz.org/* // @match *://magicisrc.kepstin.ca/* // @match *://magicisrc-beta.kepstin.ca/* // @match *://isrchunt.com/* // @run-at document-start // @grant GM.info // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM.getValue // @grant GM.setValue // @grant window.close // ==/UserScript== (async function () { 'use strict'; const scriptName = GM.info.script.name; const tabId = `[${Math.random().toString(36).substring(2, 6)}]`; console.log(`%c[${scriptName}] ${tabId} Script initialization started on ${location.href}`, 'font-weight: bold;'); /** * @typedef {Object} SiteConfig * @property {string|string[]} hostnames - Hostnames where this configuration applies. * @property {string|string[]} paths - URL paths where this configuration is active. * @property {string} buttonSelector - The CSS selector for the button to be clicked. * @property {string} [channelName] - The BroadcastChannel name for cross-tab communication. * @property {string} [messageTrigger] - The message that triggers the action on the channel. * @property {string} [menuCommandName] - The name for the userscript menu command. * @property {(RegExp|string)[]} [successUrlPatterns] - URL patterns that indicate a successful submission. * @property {boolean} [shouldCloseAfterSuccess=false] - Whether to close the tab after a successful submission. * @property {boolean} [autoClick=false] - Whether to click the button automatically on page load. * @property {() => boolean} [isNoOp] - A function that checks if the current page state represents a no-op submission (e.g., a "no changes" banner). * @property {(config: SiteConfig, triggerAction: () => Promise<boolean>) => void} [submissionHandler] - Custom logic to execute when a submission is triggered, like rate-limiting or pre-flight checks. */ const siteConfigurations = [ { hostnames: ['musicbrainz.org'], paths: ['/edit-relationships'], buttonSelector: '.rel-editor > button', autoClick: true, successUrlPatterns: [], shouldCloseAfterSuccess: false, }, { hostnames: ['musicbrainz.org'], paths: ['/edit', '/edit-relationships', '/add-cover-art'], channelName: 'mb_edit_channel', messageTrigger: 'submit-edit', buttonSelector: 'button.submit.positive[type="submit"]', menuCommandName: 'MusicBrainz: Submit Edit (All Tabs)', successUrlPatterns: [/^https?:\/\/(?:beta\.)?musicbrainz\.org\/(?!collection\/)[^/]+\/[a-f0-9\-]{36}(?:\/cover-art)?\/?$/], shouldCloseAfterSuccess: true, /** Checks for the "no changes have been made" banner on MusicBrainz. */ isNoOp: () => { const noChangesBanner = document.querySelector('.banner.warning-header'); return noChangesBanner?.textContent.includes( 'The data you have submitted does not make any changes to the data already present.' ); }, /** Wraps the submission call in a rate limiter. */ submissionHandler: (_config, triggerAction) => { rateLimitedMBSubmit(triggerAction); }, }, { hostnames: ['magicisrc.kepstin.ca', 'magicisrc-beta.kepstin.ca'], paths: ['/'], channelName: 'magicisrc_submit_channel', messageTrigger: 'submit-isrcs', buttonSelector: '[onclick^="doSubmitISRCs"]', menuCommandName: 'MagicISRC: Submit ISRCs (All Tabs)', successUrlPatterns: [/\?.*submit=1/], shouldCloseAfterSuccess: true, /** Handles pre-submission checks for MagicISRC. */ submissionHandler: (config, triggerAction) => { onDOMLoaded(async () => { const isLoggedIn = !!document.querySelector('button[onclick^="doLogout();"]'); const isErrorPage = !!document.querySelector('h1.h3')?.textContent.includes('An error occured'); const submitButtonExists = !!document.querySelector(config.buttonSelector); const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true); if (isLoggedIn && !isErrorPage && !submitButtonExists) { checkAndCloseOnSuccess(config, 'Detected MagicISRC page with no new ISRCs to submit.'); return; } if (isErrorPage && !enableReload) { handleMagicISRCReload(true); return; } sessionStorage.removeItem(RELOAD_ATTEMPTS_KEY); debugLog(`Requesting MagicISRC submit lock...`); navigator.locks.request(MAGICISRC_SUBMIT_LOCK_KEY, async () => { debugLog(`Acquired MagicISRC submit lock. Waiting 1s before submission.`, 'green'); await new Promise(resolve => setTimeout(resolve, 1000)); triggerAction(); }); }); }, }, { hostnames: ['isrchunt.com'], paths: ['/spotify/importisrc', '/deezer/importisrc'], channelName: 'isrc_hunt_submit_channel', messageTrigger: 'submit-isrcs', buttonSelector: 'form[action$="/importisrc"] button[type="submit"]', menuCommandName: 'ISRC Hunt: Submit ISRCs (All Tabs)', successUrlPatterns: [/\?.*submitted=1/], shouldCloseAfterSuccess: true, /** Handles pre-submission checks for ISRC Hunt. */ submissionHandler: (_config, triggerAction) => { debugLog(`Requesting ISRC Hunt submit lock...`); navigator.locks.request(ISRC_HUNT_SUBMIT_LOCK_KEY, async () => { debugLog(`Acquired ISRC Hunt submit lock. Waiting 1s before submission.`, 'green'); await new Promise(resolve => setTimeout(resolve, 1000)); triggerAction(); }); }, }, ]; const SUBMISSION_TRIGGERED_FLAG = 'broadcastChannelSubmissionState'; const RELOAD_ATTEMPTS_KEY = 'magicisrc_reload_attempts'; const RELOAD_LOCK_KEY = 'magicisrc-reload-lock'; const MAGICISRC_SUBMIT_LOCK_KEY = 'magicisrc-submit-lock'; const ISRC_HUNT_SUBMIT_LOCK_KEY = 'isrc-hunt-submit-lock'; const MB_SUBMIT_COORDINATION_LOCK_KEY = 'mb-submit-coordination-lock'; const MB_LAST_SUBMIT_TIMESTAMP_KEY = 'mb_last_submit_timestamp'; const DEBUG_LOG_CHANNEL_NAME = `${scriptName}_debug_log`; const MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING = 'mb_submits_per_second'; const MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING = 'mb_disable_rate_limiter'; const DISABLE_AUTO_CLOSE_SETTING = 'mb_button_clicker_disableAutoClose'; const MAGICISRC_ENABLE_AUTO_RELOAD = 'magicisrc_enableAutoReload'; const DEBUG_LOGGING_SETTING = 'debug_logging_enabled'; let registeredMenuCommandIDs = []; let debugLogChannel; /** * @summary Sends a log message to all tabs if debug logging is enabled. * @param {string} message The message to log. * @param {string} [color] Optional CSS color for the message. */ async function debugLog(message, color = 'blue') { const debugEnabled = await GM.getValue(DEBUG_LOGGING_SETTING, false); if (!debugEnabled) return; if (!debugLogChannel) { debugLogChannel = new BroadcastChannel(DEBUG_LOG_CHANNEL_NAME); } debugLogChannel.postMessage({ tabId, message, color, timestamp: new Date().toISOString(), }); } /** * @summary Executes a callback when the DOM is ready, or immediately if it's already loaded. * @param {Function} callback The function to execute. */ function onDOMLoaded(callback) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', callback); } else { callback(); } } /** * @summary Finds all site configurations that are active for the current page URL path. * @returns {SiteConfig[]} An array of active configurations. */ function getActiveConfigs() { const currentHostname = location.hostname; const currentPathname = location.pathname; return siteConfigurations.filter(config => { const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames]; const paths = Array.isArray(config.paths) ? config.paths : [config.paths]; const hostnameMatches = hostnames.some(h => currentHostname.includes(h)); const pathMatches = paths.some(p => currentPathname.endsWith(p)); return hostnameMatches && pathMatches; }); } /** * @summary Waits for a button to appear and become enabled, then clicks it. * @param {SiteConfig} config - The configuration object for the button. * @param {Function} [onClick] - An optional callback to execute immediately after the click. * @returns {Promise<boolean>} Resolves to true if clicked, false otherwise. */ async function waitForButtonAndClick(config, onClick) { return new Promise(resolve => { const checkAndClick = (obs) => { const btn = document.querySelector(config.buttonSelector); if (btn && !btn.disabled) { debugLog(`Button "${config.buttonSelector}" found and enabled. Clicking.`, 'green'); btn.click(); onClick?.(btn); if (obs) obs.disconnect(); resolve(true); return true; } return false; }; onDOMLoaded(() => { if (checkAndClick(null)) return; const observer = new MutationObserver((_, obs) => checkAndClick(obs)); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); }); }); } /** * @summary Checks if the current URL matches a success pattern for a given configuration. * @param {SiteConfig} config - The site configuration. * @param {boolean} [quiet=false] - If true, suppresses console logs. * @returns {boolean} True if the URL matches a success pattern. */ function isSubmissionSuccessful(config, quiet = false) { if (!config?.successUrlPatterns?.length) return false; const url = location.href; const isSuccess = config.successUrlPatterns.some(pattern => (typeof pattern === 'string' ? url.includes(pattern) : pattern.test(url)) ); if (isSuccess && !quiet) { debugLog(`URL "${url}" matches success pattern.`); } return isSuccess; } /** * @summary Checks if a submission was successful and closes the tab if configured to do so. * @param {SiteConfig} config - The site configuration for success checking. * @param {string|null} [preSubmissionNoOpReason=null] - A string indicating a no-op reason detected before submission. */ async function checkAndCloseOnSuccess(config, preSubmissionNoOpReason = null) { if (!config || !sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG)) return; const isPostSubmitNoOp = config.isNoOp?.() ?? false; if (isSubmissionSuccessful(config) || isPostSubmitNoOp || preSubmissionNoOpReason) { if (isPostSubmitNoOp) { debugLog(`Detected a post-submission no-op state. Treating as success.`); } else if (preSubmissionNoOpReason) { debugLog(`${preSubmissionNoOpReason} Treating as success.`); } debugLog(`Submission successful. Clearing flag.`); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); if (disableAutoClose) { debugLog(`Auto-closing is DISABLED by user setting.`, 'orange'); } else { debugLog(`Closing tab.`, 'green'); setTimeout(() => window.close(), 200); } } } /** * @summary Handles the reload logic for MagicISRC pages with exponential backoff and a Web Lock. * @param {boolean} [manual=false] - If true, bypasses the 'enableReload' check and forces the reload logic. */ async function handleMagicISRCReload(manual = false) { const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true); if (!enableReload && !manual) { debugLog(`MagicISRC automatic reload is DISABLED.`, 'orange'); return; } debugLog(`An error occurred. Requesting reload lock...`, 'red'); navigator.locks.request(RELOAD_LOCK_KEY, async () => { debugLog(`Acquired reload lock. This tab will handle the reload.`, 'red'); let attempts = parseInt(sessionStorage.getItem(RELOAD_ATTEMPTS_KEY) || '0'); attempts++; const backoffSeconds = Math.pow(2, Math.min(attempts, 6)); const jitter = Math.random(); const delay = (backoffSeconds + jitter) * 1000; debugLog(`MagicISRC error detected. Reload attempt ${attempts}. Retrying in ${Math.round(delay / 1000)}s.`, 'red'); sessionStorage.setItem(RELOAD_ATTEMPTS_KEY, attempts.toString()); await new Promise(resolve => setTimeout(resolve, delay)); debugLog(`Falling back to a full page reload to re-trigger logic.`, 'red'); location.reload(); }); } /** * @summary Sets up listeners and handlers specific to MagicISRC pages. * @description Injects a script to intercept fetch/render errors and sets up a message listener to handle them. */ function setupMagicISRC() { if (!location.hostname.includes('magicisrc')) return; debugLog(`MagicISRC page detected. Setting up special handlers.`); const script = document.createElement('script'); script.textContent = ` (() => { const post = (type) => window.postMessage({ source: '${scriptName}', type }, location.origin); const origFetch = window.fetch; window.fetch = (...args) => origFetch(...args).catch(err => { post('FETCH_ERROR'); throw err; }); const origRenderError = window.renderError; window.renderError = (...args) => { post('RENDER_ERROR'); if(origRenderError) origRenderError.apply(this, args); }; })(); `; document.documentElement.appendChild(script); script.remove(); window.addEventListener('message', (event) => { if (event.origin !== location.origin || event.data?.source !== scriptName) return; if (event.data.type === 'FETCH_ERROR' || event.data.type === 'RENDER_ERROR') { handleMagicISRCReload(); } }); } /** * @summary Registers all userscript menu commands and settings toggles. */ async function setupMenuCommands() { for (const commandId of registeredMenuCommandIDs) { try { GM_unregisterMenuCommand(commandId); } catch (e) { /* ignore */ } } registeredMenuCommandIDs = []; const registerCommand = (name, func) => { const id = GM_registerMenuCommand(name, func); registeredMenuCommandIDs.push(id); }; const settings = [ { key: DISABLE_AUTO_CLOSE_SETTING, getLabel: async (value) => `Auto Close Tabs: ${value ? 'DISABLED' : 'ENABLED'}`, onClick: async (currentValue) => GM.setValue(DISABLE_AUTO_CLOSE_SETTING, !currentValue), defaultValue: false, }, { key: MAGICISRC_ENABLE_AUTO_RELOAD, getLabel: async (value) => `MagicISRC Auto Reload: ${value ? 'ENABLED' : 'DISABLED'}`, onClick: async (currentValue) => GM.setValue(MAGICISRC_ENABLE_AUTO_RELOAD, !currentValue), defaultValue: true, }, { key: DEBUG_LOGGING_SETTING, getLabel: async (value) => `Debug Logging: ${value ? 'ENABLED' : 'DISABLED'}`, onClick: async (currentValue) => GM.setValue(DEBUG_LOGGING_SETTING, !currentValue), defaultValue: false, }, { key: MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING, getLabel: async (value) => `MusicBrainz Edit Submits / sec (Current: ${value})`, onClick: async (currentValue) => { const newValue = prompt(`Enter new max submissions per second for MusicBrainz:`, currentValue); const newRate = parseInt(newValue, 10); if (!isNaN(newRate) && newRate > 0) { await GM.setValue(MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING, newRate); } else if (newValue !== null) { alert('Please enter a valid positive number.'); } }, defaultValue: 10, }, { key: MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING, getLabel: async (value) => `MusicBrainz Rate Limiter: ${value ? 'DISABLED' : 'ENABLED'}`, onClick: async (currentValue) => GM.setValue(MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING, !currentValue), defaultValue: false, }, ]; for (const setting of settings) { const value = await GM.getValue(setting.key, setting.defaultValue); registerCommand(await setting.getLabel(value), async () => { await setting.onClick(value); await setupMenuCommands(); }); } const activeConfigs = getActiveConfigs(); const configsForMenu = activeConfigs.filter(c => !c.autoClick && c.menuCommandName); for (const config of configsForMenu) { registerCommand(config.menuCommandName, () => { const channel = new BroadcastChannel(config.channelName); channel.postMessage(config.messageTrigger); channel.close(); }); } debugLog(`Menu commands updated.`); } /** * @summary Executes a callback after ensuring the configured rate limit is not exceeded. * @param {Function} callback The function to execute. */ async function rateLimitedMBSubmit(callback) { const limiterDisabled = await GM.getValue(MUSICBRAINZ_DISABLE_RATE_LIMITER_SETTING, false); if (limiterDisabled) { debugLog('MusicBrainz rate limiter is disabled. Submitting immediately.', 'orange'); callback(); return; } const submitsPerSecond = await GM.getValue(MUSICBRAINZ_SUBMITS_PER_SECOND_SETTING, 10); const requiredInterval = 1000 / submitsPerSecond; debugLog(`Requesting MB submission lock...`); navigator.locks.request(MB_SUBMIT_COORDINATION_LOCK_KEY, async () => { debugLog(`Acquired MB submission lock.`, 'green'); const lastSubmit = await GM.getValue(MB_LAST_SUBMIT_TIMESTAMP_KEY, 0); const now = Date.now(); const elapsed = now - lastSubmit; if (elapsed < requiredInterval) { const waitTime = requiredInterval - elapsed; debugLog(`Rate limiting: waiting ${waitTime.toFixed(0)}ms...`, 'orange'); await new Promise(resolve => setTimeout(resolve, waitTime)); } await GM.setValue(MB_LAST_SUBMIT_TIMESTAMP_KEY, Date.now().toString()); debugLog(`Executing submission.`, 'darkgreen'); callback(); }); } /** * @summary Sets up listeners for specified configurations, handling auto-clicks or broadcast channel messages. * @param {SiteConfig[]} configs - An array of configuration objects. */ function setupConfigListeners(configs) { const pendingSubmissionJSON = sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG); if (pendingSubmissionJSON) { try { const pendingState = JSON.parse(pendingSubmissionJSON); const pendingConfig = siteConfigurations.find(c => c.channelName === pendingState.channel && c.messageTrigger === pendingState.trigger); if (pendingConfig && (isSubmissionSuccessful(pendingConfig, true) || pendingConfig.isNoOp?.())) { debugLog(`Found pending submission flag on a success/no-op page. Letting success handler take over.`, 'purple'); } else { const activePendingConfig = configs.find(c => c.channelName === pendingState.channel && c.messageTrigger === pendingState.trigger); if (activePendingConfig && activePendingConfig.submissionHandler) { debugLog(`Found pending submission flag on page load for "${activePendingConfig.menuCommandName}". Re-triggering handler.`, 'purple'); const triggerAction = () => waitForButtonAndClick(activePendingConfig); activePendingConfig.submissionHandler(activePendingConfig, triggerAction); } } } catch (e) { console.error(`[${scriptName}] Error parsing pending submission state:`, e); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); } } for (const config of configs) { const triggerAction = () => waitForButtonAndClick(config); if (config.autoClick) { debugLog(`Setting up auto-click for "${config.buttonSelector}".`); triggerAction(); continue; } if (config.channelName) { const channel = new BroadcastChannel(config.channelName); channel.onmessage = async (event) => { if (event.data !== config.messageTrigger) return; debugLog(`Received trigger "${event.data}".`); const triggerState = JSON.stringify({ channel: config.channelName, trigger: config.messageTrigger }); sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, triggerState); if (config.submissionHandler) { config.submissionHandler(config, triggerAction); } else { triggerAction(); } }; } } } /** * @summary Wraps a method on the history object to call a callback after it executes. * @param {'pushState'|'replaceState'} methodName The name of the history method to wrap. * @param {Function} callback The function to call after the original method. */ function wrapHistoryMethod(methodName, callback) { const original = history[methodName]; history[methodName] = function (...args) { original.apply(this, args); callback(); }; } /** * @summary Sets up listeners for URL changes to check for submission success. * @description This is a global handler that runs on all matched pages. It checks if a submission * was triggered and then determines if the current page is a success page for any configuration. */ function setupSuccessHandling() { const potentialSuccessConfigs = siteConfigurations.filter(c => c.shouldCloseAfterSuccess); if (potentialSuccessConfigs.length === 0) return; const runCheck = () => { if (!sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG)) return; for (const config of potentialSuccessConfigs) { const isSuccess = isSubmissionSuccessful(config, true); const isNoOp = config.isNoOp?.() ?? false; if (isSuccess || isNoOp) { checkAndCloseOnSuccess(config); return; } } }; onDOMLoaded(runCheck); wrapHistoryMethod('pushState', runCheck); wrapHistoryMethod('replaceState', runCheck); window.addEventListener('popstate', runCheck); } /** * @summary Main script entry point. */ async function main() { await setupMenuCommands(); const debugEnabled = await GM.getValue(DEBUG_LOGGING_SETTING, false); if (debugEnabled) { const logReceiver = new BroadcastChannel(DEBUG_LOG_CHANNEL_NAME); logReceiver.onmessage = (event) => { const { tabId: msgTabId, message, color, timestamp } = event.data; console.log(`%c[${scriptName}] [${timestamp}] ${msgTabId} ${message}`, `color: ${color}`); }; } const activeConfigs = getActiveConfigs(); if (activeConfigs.length > 0) { setupConfigListeners(activeConfigs); } setupMagicISRC(); setupSuccessHandling(); debugLog(`Initialization finished.`); } main(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址