您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Clicks specified buttons across tabs using the BroadcastChannel API and closes tabs after successful submission.
// ==UserScript== // @name Click buttons across tabs // @namespace https://musicbrainz.org/user/chaban // @version 2.1 // @tag ai-created // @description Clicks specified buttons across tabs using the BroadcastChannel API and closes tabs after successful submission. // @author chaban // @license MIT // @match *://*.musicbrainz.org/* // @match *://magicisrc.kepstin.ca/* // @match *://magicisrc-beta.kepstin.ca/* // @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; console.log(`%c[${scriptName}] Script initialization started on ${location.href}`, 'font-weight: bold;'); /** * @typedef {Object} SiteConfig * @property {string|string[]} hostnames - A single hostname string or an array of hostname strings. * @property {string|string[]} paths - A single path string or an array of path strings (must match ending of pathname). * @property {string} channelName - The name of the BroadcastChannel to use for this site's button click. * @property {string} messageTrigger - The message data that triggers the button click. * @property {string} buttonSelector - The CSS selector for the button to be clicked. * @property {string} menuCommandName - The name to display in the Tampermonkey/Greasemonkey menu for the click action. * @property {(RegExp|string)[]} [successUrlPatterns] - An array of RegExp or string patterns to match against the URL to indicate a successful submission. * @property {boolean} [shouldCloseAfterSuccess=false] - Whether to attempt closing the tab after a successful submission. Note: Browser security heavily restricts `window.close()`. */ /** * Configuration for different websites and their button click settings. * @type {SiteConfig[]} */ const siteConfigurations = [ { hostnames: ['musicbrainz.org'], paths: [ '/edit', '/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\/[^/]+\/[a-f0-9\-]{36}(?:\/cover-art)?\/?$/, ], shouldCloseAfterSuccess: true }, { 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 } ]; const SUBMISSION_TRIGGERED_FLAG = 'broadcastChannelSubmissionState'; const GLOBAL_CLOSE_TAB_CHANNEL_NAME = 'global_close_tab_channel'; const GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER = 'close-this-tab'; const GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME = 'Global: Close This Tab (All Tabs)'; // --- Constants for click delay calculation --- const MIN_CLICK_DELAY_SECONDS = 0; const DELAY_SCALING_FACTOR = 120; const DELAY_MODE_SETTING = 'mb_button_clicker_delayMode'; const STATIC_MAX_DELAY_SETTING = 'mb_button_clicker_staticMaxDelay'; const DISABLE_AUTO_CLOSE_SETTING = 'mb_button_clicker_disableAutoClose'; let registeredMenuCommandIDs = []; /** * Sends a message to the specified BroadcastChannel. * @param {string} channelName * @param {string} message */ function sendMessageToChannel(channelName, message) { try { new BroadcastChannel(channelName).postMessage(message); console.log(`[${scriptName}] Sent message "${message}" to channel "${channelName}".`); } catch (error) { console.error(`[${scriptName}] Error sending message to channel "${channelName}":`, error); } } /** * Checks if the current page indicates a successful submission based on the given config's URL patterns. * @param {SiteConfig} config - The site configuration. * @param {boolean} [quiet=false] - If true, suppresses console logs for matches/no matches. * @returns {boolean} */ function isSubmissionSuccessful(config, quiet = false) { if (!config.successUrlPatterns || config.successUrlPatterns.length === 0) { return false; } for (const pattern of config.successUrlPatterns) { const matchResult = (typeof pattern === 'string') ? location.href.includes(pattern) : pattern.test(location.href); if (matchResult) { if (!quiet) { console.log(`[${scriptName}] URL "${location.href}" matches success pattern "${pattern}".`); } return true; } } if (!quiet) { console.log(`[${scriptName}] URL "${location.href}" does not match any success pattern.`); } return false; } /** * Attempts to close the current tab. * @param {number} delayMs - Optional delay in milliseconds before attempting to close. * @returns {void} */ function attemptCloseTab(delayMs = 200) { console.log(`[${scriptName}] Attempting to close tab in ${delayMs}ms.`); setTimeout(() => { try { window.close(); console.log(`[${scriptName}] Successfully called window.close() after delay.`); } catch (e) { console.warn(`[${scriptName}] Failed to close tab automatically via window.close(). This is expected due to browser security restrictions.`, e); } }, delayMs); } /** * Checks the current page against the success criteria for the relevant site, * and closes the tab if successful, unless auto-close is disabled. * @param {SiteConfig} currentConfigForSuccessCheck - The site config relevant to the current hostname. * @returns {Promise<void>} */ async function checkAndCloseIfSuccessful(currentConfigForSuccessCheck) { const storedSubmissionState = sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG); if (!storedSubmissionState) { return; } let submissionData; try { submissionData = JSON.parse(storedSubmissionState); } catch (e) { console.error(`[${scriptName}] Error parsing submission state from sessionStorage:`, e); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); return; } const { triggered } = submissionData; const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); if (triggered && currentConfigForSuccessCheck.shouldCloseAfterSuccess) { console.log(`[${scriptName}] Checking for submission success on "${location.href}".`); if (isSubmissionSuccessful(currentConfigForSuccessCheck)) { if (disableAutoClose) { console.info(`%c[${scriptName}] Submission successful, but automatic tab closing is DISABLED by user setting.`, 'color: orange; font-weight: bold;'); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); } else { console.log(`%c[${scriptName}] Submission successful. Closing tab.`, 'color: green; font-weight: bold;'); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); attemptCloseTab(); } } else { console.info(`%c[${scriptName}] Submission was triggered, but URL does not match success criteria.`, 'color: orange;'); } } else { console.log(`[${scriptName}] Not checking for success: triggered is false, or closing is not configured for this site.`); } } /** * Retrieves the current delay mode from storage. * @returns {Promise<string>} 'dynamic' or 'static'. Defaults to 'dynamic'. */ async function getDelayMode() { return await GM.getValue(DELAY_MODE_SETTING, 'dynamic'); } /** * Determines and returns the current minimum and maximum click delays based on settings. * @returns {Promise<{min: number, max: number}>} */ async function getCurrentClickDelayRange() { const delayMode = await getDelayMode(); let currentMinDelay; let currentMaxDelay; let delayModeLog; if (delayMode === 'static') { currentMinDelay = 0; currentMaxDelay = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6); delayModeLog = `Delays set to STATIC (random 0 to ${currentMaxDelay}s).`; } else { const logicalCores = Math.max(1, navigator.hardwareConcurrency || 1); currentMinDelay = MIN_CLICK_DELAY_SECONDS; currentMaxDelay = Math.max(currentMinDelay, Math.round(DELAY_SCALING_FACTOR / logicalCores)); delayModeLog = `Delays set to DYNAMIC (based on ${logicalCores} cores): ${currentMinDelay} to ${currentMaxDelay} seconds.`; } console.log(`[${scriptName}] ${delayModeLog}`); return { min: currentMinDelay, max: currentMaxDelay }; } /** * Sets up menu commands for the userscript. * This function is responsible for clearing and re-registering ALL commands * to ensure dynamic display names and conditional commands work correctly. */ async function setupMenuCommands() { registeredMenuCommandIDs.forEach(id => { try { GM_unregisterMenuCommand(id); } catch (e) { } }); registeredMenuCommandIDs = []; const registerCommand = (name, func) => { const id = GM_registerMenuCommand(name, func); registeredMenuCommandIDs.push(id); }; const delayMode = await getDelayMode(); const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); // --- 1. Delay Mode Toggle Command --- if (delayMode === 'dynamic') { registerCommand('Delay Mode: DYNAMIC', async () => { await GM.setValue(DELAY_MODE_SETTING, 'static'); console.log(`[${scriptName}] Delay mode switched to: STATIC.`); await getCurrentClickDelayRange(); await setupMenuCommands(); }); } else { registerCommand('Delay Mode: STATIC', async () => { await GM.setValue(DELAY_MODE_SETTING, 'dynamic'); console.log(`[${scriptName}] Delay mode switched to: DYNAMIC.`); await getCurrentClickDelayRange(); await setupMenuCommands(); }); const currentStaticMaxValue = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6); // --- 2. Set Static Max Delay Command (only in static mode) --- registerCommand(`Set Static Max Delay (Current: ${currentStaticMaxValue}s)`, async () => { let newValue = prompt(`Enter maximum STATIC delay in seconds (Current: ${currentStaticMaxValue}). A random delay between 0 and this value (inclusive) will be used.`); if (newValue === null) return; newValue = parseFloat(newValue); if (isNaN(newValue) || newValue < 0) { console.warn(`[${scriptName}] Invalid input for static max delay: "${newValue}". Please enter a non-negative number.`); alert("Invalid input. Please enter a non-negative number."); return; } const actualNewValue = Math.floor(newValue); await GM.setValue(STATIC_MAX_DELAY_SETTING, actualNewValue); console.log(`[${scriptName}] Static maximum delay set to ${actualNewValue} seconds.`); await getCurrentClickDelayRange(); await setupMenuCommands(); }); } // --- 3. Auto Close Tabs Toggle Command --- registerCommand(`Auto Close Tabs: ${disableAutoClose ? 'DISABLED' : 'ENABLED'}`, async () => { const currentState = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); const newState = !currentState; await GM.setValue(DISABLE_AUTO_CLOSE_SETTING, newState); console.log(`[${scriptName}] Automatic tab closing is now ${newState ? 'DISABLED' : 'ENABLED'}.`); await setupMenuCommands(); }); // --- 4. Register site-specific button click commands --- const currentHostname = location.hostname; const currentPathname = location.pathname; const configForButtonClicking = siteConfigurations.find(config => { const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames]; const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname)); if (hostnameMatches) { const paths = Array.isArray(config.paths) ? config.paths : [config.paths]; return paths.some(pathPattern => currentPathname.endsWith(pathPattern)); } return false; }); if (configForButtonClicking && configForButtonClicking.menuCommandName) { registerCommand(configForButtonClicking.menuCommandName, () => { const submissionState = { triggered: true }; sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, JSON.stringify(submissionState)); console.log(`[${scriptName}] Menu command triggered. Stored submission state: ${JSON.stringify(submissionState)}.`); sendMessageToChannel(configForButtonClicking.channelName, configForButtonClicking.messageTrigger); }); console.log(`[${scriptName}] Registered menu command "${configForButtonClicking.menuCommandName}".`); } // --- 5. Register Global close tab command --- const configForSuccessCheck = siteConfigurations.find(config => { const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames]; return hostnames.some(hostname => currentHostname.endsWith(hostname)) && config.shouldCloseAfterSuccess; }); if (configForSuccessCheck && isSubmissionSuccessful(configForSuccessCheck, true)) { registerCommand(GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME, () => { console.log(`[${scriptName}] Global close menu command triggered.`); sendMessageToChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME, GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER); }); console.log(`[${scriptName}] Registered global close menu command.`); } console.log(`[${scriptName}] Menu commands setup finished. Total commands: ${registeredMenuCommandIDs.length}`); } /** * Main initialization function for the userscript. * This function focuses on setting up event listeners and core logic. */ async function initializeScript() { const currentHostname = location.hostname; const currentPathname = location.pathname; let configForButtonClicking = null; let configForSuccessCheck = null; // --- Find relevant configurations --- for (const config of siteConfigurations) { const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames]; const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname)); if (hostnameMatches) { const paths = Array.isArray(config.paths) ? config.paths : [config.paths]; const pathMatchesForClicking = paths.some(pathPattern => currentPathname.endsWith(pathPattern)); if (pathMatchesForClicking) { configForButtonClicking = config; console.log(`[${scriptName}] Found button-clicking config: ${config.channelName}`); } if (config.shouldCloseAfterSuccess) { configForSuccessCheck = config; console.log(`[${scriptName}] Identified success-check config for this hostname: ${config.channelName}`); } } } // --- Get and log current delay range --- await getCurrentClickDelayRange(); // --- Log current auto-close state --- const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); console.log(`[${scriptName}] Automatic tab closing is currently ${disableAutoClose ? 'DISABLED' : 'ENABLED'}.`); // --- Part 1: Setup BroadcastChannel listener for button clicks --- if (configForButtonClicking) { console.log(`[${scriptName}] Setting up button-clicking logic for "${configForButtonClicking.channelName}".`); try { const channel = new BroadcastChannel(configForButtonClicking.channelName); channel.addEventListener('message', async (event) => { if (event.data === configForButtonClicking.messageTrigger) { console.log(`[${scriptName}] Received trigger message "${event.data}".`); const btn = document.querySelector(configForButtonClicking.buttonSelector); if (btn) { const submissionState = { triggered: true }; sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, JSON.stringify(submissionState)); console.log(`[${scriptName}] Stored submission state: ${JSON.stringify(submissionState)}.`); const { min: currentMinDelay, max: currentMaxDelay } = await getCurrentClickDelayRange(); const delaySeconds = Math.floor(Math.random() * (currentMaxDelay - currentMinDelay + 1)) + currentMinDelay; const delayMs = delaySeconds * 1000; console.log(`[${scriptName}] Attempting to click button in ${delaySeconds} seconds with selector "${configForButtonClicking.buttonSelector}".`); setTimeout(() => { btn.click(); }, delayMs); } else { console.warn(`[${scriptName}] Button with selector "${configForButtonClicking.buttonSelector}" not found.`); } } }); console.log(`[${scriptName}] Listener active for button clicks on channel "${configForButtonClicking.channelName}".`); } catch (error) { console.error(`[${scriptName}] Error initializing BroadcastChannel:`, error); } } else { console.log(`[${scriptName}] No button-clicking config found for this page.`); } // --- Part 2: Immediate check for pending submission success & URL change monitoring --- if (configForSuccessCheck) { await checkAndCloseIfSuccessful(configForSuccessCheck); let lastUrl = location.href; const urlChangedHandler = async () => { if (location.href !== lastUrl) { console.log(`%c[${scriptName}] URL changed from "${lastUrl}" to "${location.href}". Re-checking for success.`, 'color: purple; font-weight: bold;'); lastUrl = location.href; await checkAndCloseIfSuccessful(configForSuccessCheck); } }; window.addEventListener('popstate', urlChangedHandler); const originalPushState = history.pushState; history.pushState = function() { originalPushState.apply(this, arguments); urlChangedHandler(); }; const originalReplaceState = history.replaceState; history.replaceState = function() { originalReplaceState.apply(this, arguments); urlChangedHandler(); }; console.log(`[${scriptName}] URL change listeners activated.`); // --- Part 3: Setup GLOBAL BroadcastChannel listener for closing tabs --- if (isSubmissionSuccessful(configForSuccessCheck)) { console.log(`[${scriptName}] Current URL matches a success pattern. Setting up global close listener.`); try { const globalCloseChannel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME); globalCloseChannel.addEventListener('message', (event) => { if (event.data === GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER) { console.log(`[${scriptName}] Received global close request.`); attemptCloseTab(50); } }); console.log(`[${scriptName}] Global close channel listener active.`); } catch (error) { console.error(`[${scriptName}] Error initializing global BroadcastChannel:`, error); } } else { console.log(`[${scriptName}] Current URL does NOT match a success pattern. Global close listener skipped.`); } } else { console.log(`[${scriptName}] No success-check config found for this hostname. Not monitoring for closure.`); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); } console.log(`%c[${scriptName}] Script initialization finished.`, 'font-weight: bold;'); } await setupMenuCommands(); await initializeScript(); })();