您需要先安装一个扩展,例如 篡改猴、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 3.0 // @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/* // @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 * @property {string|string[]} paths * @property {string} buttonSelector * @property {string} [channelName] * @property {string} [messageTrigger] * @property {string} [menuCommandName] * @property {(RegExp|string)[]} [successUrlPatterns] * @property {boolean} [shouldCloseAfterSuccess=false] * @property {boolean} [autoClick=false] * @property {boolean} [disableDelay=false] */ const siteConfigurations = [ { hostnames: ['musicbrainz.org'], paths: ['/edit-relationships'], buttonSelector: '.rel-editor > button', autoClick: true, disableDelay: 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, }, { 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)'; 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'; const MAGICISRC_ENABLE_AUTO_RELOAD = 'magicisrc_enableAutoReload'; let registeredMenuCommandIDs = []; /** * 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(); } } /** * Finds all site configurations that are active for the current page URL path. * This is used to determine which buttons to listen for on the current page. * @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; }); } /** * Waits for a button to appear and become enabled, then clicks it after a configured delay. * @param {string} selector - The CSS selector for the button. * @param {boolean} disableDelay - If true, clicks immediately. * @returns {Promise<boolean>} Resolves to true if clicked, false otherwise. */ async function waitForButtonAndClick(selector, disableDelay) { return new Promise(resolve => { const checkAndClick = async (obs) => { const btn = document.querySelector(selector); if (btn && !btn.disabled) { console.log(`%c[${scriptName}] Button "${selector}" found and enabled. Clicking.`, 'color: green; font-weight: bold;'); const delayMs = await getCalculatedDelay(disableDelay); setTimeout(() => btn.click(), delayMs); if (obs) obs.disconnect(); resolve(true); return true; } return false; }; onDOMLoaded(() => { checkAndClick(null).then(clicked => { if (clicked) return; const observer = new MutationObserver((_, obs) => checkAndClick(obs)); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); }); }); }); } /** * 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} */ 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) { console.log(`[${scriptName}] URL "${url}" matches success pattern.`); } return isSuccess; } /** * Checks if a submission was successful and closes the tab if configured to do so. * This now also includes checking for the "no changes" banner on MusicBrainz. * @param {SiteConfig} config - The site configuration for success checking. */ async function checkAndCloseOnSuccess(config) { if (!config || sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) !== 'true') return; const noChangesBanner = document.querySelector('.banner.warning-header'); const isNoOpSubmission = noChangesBanner?.textContent.includes( 'The data you have submitted does not make any changes to the data already present.' ); if (isSubmissionSuccessful(config) || isNoOpSubmission) { if (isNoOpSubmission) { console.log(`[${scriptName}] Detected 'no changes' banner. Treating as success.`); } console.log(`[${scriptName}] Submission successful. Clearing flag.`); sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG); const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); if (disableAutoClose) { console.info(`%c[${scriptName}] Auto-closing is DISABLED by user setting.`, 'color: orange;'); } else { console.log(`%c[${scriptName}] Closing tab.`, 'color: green; font-weight: bold;'); setTimeout(() => window.close(), 200); } } } /** * Sets up listeners and handlers specific to MagicISRC pages. */ function setupMagicISRC() { if (!location.hostname.includes('magicisrc')) return; console.log(`[${scriptName}] MagicISRC page detected. Setting up special handlers.`); const script = document.createElement('script'); script.textContent = ` (() => { const post = (type, data) => window.postMessage({ source: '${scriptName}', type, ...data }, location.origin); const origFetch = window.fetch; window.fetch = (...args) => origFetch(...args).catch(err => { post('FETCH_ERROR'); throw err; }); window.renderError = () => { post('RENDER_ERROR'); }; })(); `; document.documentElement.appendChild(script); script.remove(); window.addEventListener('message', async (event) => { if (event.origin !== location.origin || event.data?.source !== scriptName) return; if (event.data.type === 'FETCH_ERROR' || event.data.type === 'RENDER_ERROR') { console.warn(`%c[${scriptName}] Intercepted MagicISRC error: ${event.data.type}`, 'color: red;'); const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true); if (enableReload) { const delay = await getCalculatedDelay(); console.log(`Reloading in ${delay / 1000}s.`); setTimeout(() => location.reload(), delay); } } }); if (sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) === 'true') { const config = getActiveConfigs().find(c => c.channelName === 'magicisrc_submit_channel'); if (config) { waitForButtonAndClick(config.buttonSelector, config.disableDelay); } } } /** * Calculates a random click delay based on user settings. * @param {boolean} [disable=false] - If true, forces a 0ms delay. * @returns {Promise<number>} Delay in milliseconds. */ async function getCalculatedDelay(disable = false) { if (disable) return 0; const delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic'); if (delayMode === 'static') { const max = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6); return Math.floor(Math.random() * (max + 1)) * 1000; } const cores = Math.max(1, navigator.hardwareConcurrency || 1); const max = Math.max(0, Math.round(120 / cores)); return Math.floor(Math.random() * (max + 1)) * 1000; } /** * Registers all userscript menu commands. */ 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 delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic'); const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false); const enableMagicISRCReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true); registerCommand(`Delay Mode: ${delayMode === 'dynamic' ? 'DYNAMIC' : 'STATIC'}`, async () => { await GM.setValue(DELAY_MODE_SETTING, delayMode === 'dynamic' ? 'static' : 'dynamic'); await setupMenuCommands(); }); if (delayMode === 'static') { const currentStaticMax = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6); registerCommand(`Set Static Max Delay (Current: ${currentStaticMax}s)`, async () => { const newValue = prompt(`Enter new max delay in seconds:`, currentStaticMax); if (newValue !== null && !isNaN(newValue) && parseFloat(newValue) >= 0) { await GM.setValue(STATIC_MAX_DELAY_SETTING, parseFloat(newValue)); await setupMenuCommands(); } }); } registerCommand(`Auto Close Tabs: ${disableAutoClose ? 'DISABLED' : 'ENABLED'}`, async () => { await GM.setValue(DISABLE_AUTO_CLOSE_SETTING, !disableAutoClose); await setupMenuCommands(); }); registerCommand(`MagicISRC Auto Reload: ${enableMagicISRCReload ? 'ENABLED' : 'ENABLED'}`, async () => { await GM.setValue(MAGICISRC_ENABLE_AUTO_RELOAD, !enableMagicISRCReload); 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(); }); } const successConfig = siteConfigurations.find(c => (Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames)) && c.shouldCloseAfterSuccess && isSubmissionSuccessful(c, true) ); if (successConfig) { registerCommand(GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME, () => { const channel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME); channel.postMessage(GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER); channel.close(); }); } console.log(`[${scriptName}] Menu commands updated. Total: ${registeredMenuCommandIDs.length}`); } /** * Sets up listeners for broadcast channel messages to click buttons. * @param {SiteConfig[]} configs - The active configurations for the current page. */ function setupConfigListeners(configs) { for (const config of configs) { if (config.autoClick) { console.log(`[${scriptName}] Setting up auto-click for "${config.buttonSelector}".`); waitForButtonAndClick(config.buttonSelector, config.disableDelay); } else if (config.channelName) { const channel = new BroadcastChannel(config.channelName); channel.onmessage = (event) => { if (event.data === config.messageTrigger) { console.log(`[${scriptName}] Received trigger "${event.data}". Clicking button.`); sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, 'true'); waitForButtonAndClick(config.buttonSelector, config.disableDelay); } }; } } } /** * Sets up listeners for URL changes to check for submission success. * @param {SiteConfig} config - The active configuration that should be checked for success. */ function setupSuccessHandling(config) { if (!config) return; const runCheck = () => checkAndCloseOnSuccess(config); if (isSubmissionSuccessful(config, true)) { console.log(`[${scriptName}] Success URL detected at page start, checking immediately.`); runCheck(); } else { console.log(`[${scriptName}] Deferring success check until DOM is loaded.`); onDOMLoaded(runCheck); } const originalPushState = history.pushState; history.pushState = function(...args) { originalPushState.apply(this, args); runCheck(); }; const originalReplaceState = history.replaceState; history.replaceState = function(...args) { originalReplaceState.apply(this, args); runCheck(); }; window.addEventListener('popstate', runCheck); if (isSubmissionSuccessful(config)) { const closeChannel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME); closeChannel.onmessage = (event) => { if (event.data === GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER) { setTimeout(() => window.close(), 50); } }; } } /** * Main script entry point. */ async function main() { await setupMenuCommands(); const activeConfigs = getActiveConfigs(); if (activeConfigs.length > 0) { setupConfigListeners(activeConfigs); } const successConfig = siteConfigurations.find(c => (Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames)) && c.shouldCloseAfterSuccess ); setupMagicISRC(); setupSuccessHandling(successConfig); console.log(`%c[${scriptName}] Initialization finished.`, 'font-weight: bold;'); } main(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址