Click buttons across tabs

Clicks specified buttons across tabs using the BroadcastChannel API and closes tabs after successful submission.

当前为 2025-06-12 提交的版本,查看 最新版本

// ==UserScript==
// @name        Click buttons across tabs
// @namespace   https://musicbrainz.org/user/chaban
// @version     2.0
// @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       window.close
// ==/UserScript==

(function () {
    'use strict';

    const scriptName = GM.info.script.name;

    /**
     * @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)';

    /**
     * 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.
     * @returns {boolean}
     */
    function isSubmissionSuccessful(config) {
        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) {
                console.log(`[${scriptName}] URL "${location.href}" matches success pattern "${pattern}".`);
                return true;
            }
        }
        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.
     * @param {SiteConfig} currentConfigForSuccessCheck - The site config relevant to the current hostname.
     */
    function checkAndCloseIfSuccessful(currentConfigForSuccessCheck) {
        const storedSubmissionState = sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG);
        if (!storedSubmissionState) {
            return; // No pending submission
        }

        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;

        if (triggered && currentConfigForSuccessCheck.shouldCloseAfterSuccess) {
            console.log(`[${scriptName}] Checking for submission success on "${location.href}".`);
            if (isSubmissionSuccessful(currentConfigForSuccessCheck)) {
                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.`);
        }
    }


    /**
     * Main initialization function for the userscript.
     */
    function initializeScript() {
        console.log(`%c[${scriptName}] Script initialized on ${location.href}`, 'color: blue; font-weight: bold;');
        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}`);
                }
            }
        }


        // --- Part 1: Setup BroadcastChannel listener and GM_registerMenuCommand 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', (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)}.`);
                            btn.click();
                        } 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);
            }

            if (typeof GM_registerMenuCommand !== 'undefined' && configForButtonClicking.menuCommandName) {
                GM_registerMenuCommand(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}".`);
            }
        } 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) {
            // Immediate check on page load (due to @run-at document-start)
            checkAndCloseIfSuccessful(configForSuccessCheck);

            // Setup URL change monitoring for pages that might update via history API
            let lastUrl = location.href;
            const urlChangedHandler = () => {
                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;
                    checkAndCloseIfSuccessful(configForSuccessCheck);
                }
            };

            // Listen for popstate events
            window.addEventListener('popstate', urlChangedHandler);

            // Monkey-patch history API methods
            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 and GM_registerMenuCommand for closing tabs ---
            if (isSubmissionSuccessful(configForSuccessCheck)) {
                console.log(`[${scriptName}] Current URL matches a success pattern. Setting up global close functionality.`);
                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 close BroadcastChannel:`, error);
                }

                if (typeof GM_registerMenuCommand !== 'undefined') {
                    GM_registerMenuCommand(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.`);
                }
            } else {
                console.log(`[${scriptName}] Current URL does NOT match a success pattern. Global close functionality skipped.`);
            }

        } else {
            console.log(`[${scriptName}] No success-check config found for this hostname. Not monitoring for closure or registering global close command.`);
            sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
        }

        console.log(`%c[${scriptName}] Script initialization finished.`, 'color: blue; font-weight: bold;');
    }

    initializeScript();
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。