click it for you

在符合正則表達式的網址上自動點擊指定的元素。

// ==UserScript==
// @name         click it for you
// @name:zh-TW   為你自動點擊
// @name:ja      あなたのためにクリック
// @name:en      click it for you
// @name:de      Für dich klicken
// @name:es      Clic automático para ti
// @description  在符合正則表達式的網址上自動點擊指定的元素。
// @description:zh-TW 在符合正則表達式的網址上自動點擊指定的元素。
// @description:ja 正規表現に一致するURLで指定された要素を自動的にクリックします。
// @description:en Automatically clicks specified elements on URLs matching a regular expression.
// @description:de Klickt automatisch auf angegebene Elemente auf URLs, die mit einem regulären Ausdruck übereinstimmen.
// @description:es Hace clic automáticamente en elementos especificados en URLs que coinciden con una expresión regular.

// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_info
// @version      1.0.5

// @author       Max
// @namespace    https://github.com/Max46656
// @license      MPL2.0
// ==/UserScript==

class RuleManager {
    clickRules;

    constructor() {
        this.clickRules = GM_getValue('clickRules', { rules: [] });
    }

    addRule(newRule) {
        this.clickRules.rules.push(newRule);
        this.updateRules();
    }

    updateRule(index, updatedRule) {
        this.clickRules.rules[index] = updatedRule;
        this.updateRules();
    }

    deleteRule(index) {
        this.clickRules.rules.splice(index, 1);
        this.updateRules();
    }

    updateRules() {
        GM_setValue('clickRules', this.clickRules);
    }
}

class WebElementHandler {
    ruleManager;
    clickTaskManager;
    i18n = {
        'zh-TW': {
            title: '自動點擊設定',
            matchingRules: '符合的規則',
            noMatchingRules: '當前網址無符合的規則。',
            addRuleSection: '新增規則',
            ruleName: '規則名稱:',
            urlPattern: '網址正則表達式:',
            selectorType: '選擇器類型:',
            selector: '選擇器:',
            nthElement: '第幾個元素(從 1 開始):',
            clickDelay: '點擊延遲(毫秒):',
            addRule: '新增規則',
            save: '儲存',
            delete: '刪除',
            ruleNamePlaceholder: '例如:我的規則',
            urlPatternPlaceholder: '例如:https://example\\.com/.*',
            selectorPlaceholder: '例如:button.submit 或 //button[@class="submit"]',
            invalidRegex: '無效的正則表達式',
            invalidSelector: '無效的選擇器'
        },
        'en': {
            title: 'Auto Click Configuration',
            matchingRules: 'Matching Rules',
            noMatchingRules: 'No rules match the current URL.',
            addRuleSection: 'Add New Rule',
            ruleName: 'Rule Name:',
            urlPattern: 'URL Pattern (Regex):',
            selectorType: 'Selector Type:',
            selector: 'Selector:',
            nthElement: 'Nth Element (1-based):',
            clickDelay: 'Click Delay (ms):',
            addRule: 'Add Rule',
            save: 'Save',
            delete: 'Delete',
            ruleNamePlaceholder: 'e.g., My Rule',
            urlPatternPlaceholder: 'e.g., https://example\\.com/.*',
            selectorPlaceholder: 'e.g., button.submit or //button[@class="submit"]',
            invalidRegex: 'Invalid regular expression',
            invalidSelector: 'Invalid selector'
        },
        'ja': {
            title: '自動クリック設定',
            matchingRules: '一致するルール',
            noMatchingRules: '現在のURLに一致するルールはありません。',
            addRuleSection: '新しいルールを追加',
            ruleName: 'ルール名:',
            urlPattern: 'URLパターン(正規表現):',
            selectorType: 'セレクタタイプ:',
            selector: 'セレクタ:',
            nthElement: '何番目の要素(1から):',
            clickDelay: 'クリック遅延(ミリ秒):',
            addRule: 'ルールを追加',
            save: '儲存',
            delete: '削除',
            ruleNamePlaceholder: '例:マイルール',
            urlPatternPlaceholder: '例:https://example\\.com/.*',
            selectorPlaceholder: '例:button.submit または //button[@class="submit"]',
            invalidRegex: '無効な正規表現',
            invalidSelector: '無効なセレクター'
        },
        'de': {
            title: 'Automatische Klick-Einstellungen',
            matchingRules: 'Passende Regeln',
            noMatchingRules: 'Keine Regeln passen zur aktuellen URL.',
            addRuleSection: 'Neue Regel hinzufügen',
            ruleName: 'Regelname:',
            urlPattern: 'URL-Muster (Regulärer Ausdruck):',
            selectorType: 'Selektortyp:',
            selector: 'Selektor:',
            nthElement: 'N-tes Element (ab 1):',
            clickDelay: 'Klickverzögerung (ms):',
            addRule: 'Regel hinzufügen',
            save: 'Speichern',
            delete: 'Löschen',
            ruleNamePlaceholder: 'Beispiel: Meine Regel',
            urlPatternPlaceholder: 'Beispiel: https://example\\.com/.*',
            selectorPlaceholder: 'Beispiel: button.submit oder //button[@class="submit"]',
            invalidRegex: 'Ungültiger regulärer Ausdruck',
            invalidSelector: 'Ungültiger Selektor'
        },
        'es': {
            title: 'Configuración de Clic Automático',
            matchingRules: 'Reglas Coincidentes',
            noMatchingRules: 'No hay reglas que coincidan con la URL actual.',
            addRuleSection: 'Agregar Nueva Regla',
            ruleName: 'Nombre de la Regla:',
            urlPattern: 'Patrón de URL (Regex):',
            selectorType: 'Tipo de Selector:',
            selector: 'Selector:',
            nthElement: 'N-ésimo Elemento (desde 1):',
            clickDelay: 'Retraso de Clic (ms):',
            addRule: 'Agregar Regla',
            save: 'Guardar',
            delete: 'Eliminar',
            ruleNamePlaceholder: 'Ejemplo: Mi Regla',
            urlPatternPlaceholder: 'Ejemplo: https://example\\.com/.*',
            selectorPlaceholder: 'Ejemplo: button.submit o //button[@class="submit"]',
            invalidRegex: 'Expresión regular inválida',
            invalidSelector: 'Selector inválido'
        }
    };

    constructor(ruleManager, clickTaskManager) {
        this.ruleManager = ruleManager;
        this.clickTaskManager = clickTaskManager;
        this.setupUrlChangeListener();
    }

    // 獲取選單標題(用於 registerMenu)
    getMenuTitle() {
        return this.i18n[this.getLanguage()].title;
    }

    // 獲取當前語言
    getLanguage() {
        const lang = navigator.language || navigator.userLanguage;
        if (lang.startsWith('zh')) return 'zh-TW';
        if (lang.startsWith('ja')) return 'ja';
        if (lang.startsWith('de')) return 'de';
        if (lang.startsWith('es')) return 'es';
        return 'en';
    }

    // 驗證規則輸入
    validateRule(rule) {
        const i18n = this.i18n[this.getLanguage()];
        try {
            new RegExp(rule.urlPattern);
        } catch (e) {
            alert(`${i18n.invalidRegex}: ${rule.urlPattern}`);
            return false;
        }
        if (!rule.selector || !['css', 'xpath'].includes(rule.selectorType)) {
            alert(`${i18n.invalidSelector}: ${rule.selector}`);
            return false;
        }
        return true;
    }

    // 創建單個規則的 HTML 結構
    createRuleElement(rule, ruleIndex) {
        const i18n = this.i18n[this.getLanguage()];
        const ruleDiv = document.createElement('div');
        ruleDiv.innerHTML = `
                <div class="ruleHeader" id="ruleHeader${ruleIndex}">
                    <strong>${rule.ruleName || `規則 ${ruleIndex + 1}`}</strong>
                </div>
                <div class="readRule" id="readRule${ruleIndex}" style="display: none;">
                    <label>${i18n.ruleName}</label>
                    <input type="text" id="updateRuleName${ruleIndex}" value="${rule.ruleName || ''}">
                    <label>${i18n.urlPattern}</label>
                    <input type="text" id="updateUrlPattern${ruleIndex}" value="${rule.urlPattern}">
                    <label>${i18n.selectorType}</label>
                    <select id="updateSelectorType${ruleIndex}">
                        <option value="css" ${rule.selectorType === 'css' ? 'selected' : ''}>CSS</option>
                        <option value="xpath" ${rule.selectorType === 'xpath' ? 'selected' : ''}>XPath</option>
                    </select>
                    <label>${i18n.selector}</label>
                    <input type="text" id="updateSelector${ruleIndex}" value="${rule.selector}">
                    <label>${i18n.nthElement}</label>
                    <input type="number" id="updateNthElement${ruleIndex}" min="1" value="${rule.nthElement}">
                    <label>${i18n.clickDelay}</label>
                    <input type="number" id="updateClickDelay${ruleIndex}" min="100" value="${rule.clickDelay || 200}">
                    <button id="updateRule${ruleIndex}">${i18n.save}</button>
                    <button id="deleteRule${ruleIndex}">${i18n.delete}</button>
                </div>
            `;
            return ruleDiv;
        }

        // 創建組態選單
        createMenuElement() {
            const i18n = this.i18n[this.getLanguage()];
            const menu = document.createElement('div');
            menu.style.position = 'fixed';
            menu.style.top = '10px';
            menu.style.right = '10px';
            menu.style.background = 'rgb(36, 36, 36)';
            menu.style.color = 'rgb(204, 204, 204)';
            menu.style.border = '1px solid rgb(80, 80, 80)';
            menu.style.padding = '10px';
            menu.style.zIndex = '10000';
            menu.style.maxWidth = '400px';
            menu.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
            menu.innerHTML = `
                <style>
                    #autoClickMenu {
                        overflow-y: auto;
                        max-height: 80vh;
                    }
                    #autoClickMenu input, #autoClickMenu select, #autoClickMenu button {
                        background: rgb(50, 50, 50);
                        color: rgb(204, 204, 204);
                        border: 1px solid rgb(80, 80, 80);
                        margin: 5px 0;
                        padding: 5px;
                        width: 100%;
                        box-sizing: border-box;
                    }
                    #autoClickMenu button {
                        cursor: pointer;
                    }
                    #autoClickMenu button:hover {
                        background: rgb(70, 70, 70);
                    }
                    #autoClickMenu label {
                        margin-top: 5px;
                        display: block;
                    }
                    #autoClickMenu .ruleHeader {
                        cursor: pointer;
                        background: rgb(50, 50, 50);
                        padding: 5px;
                        margin: 5px 0;
                        border-radius: 3px;
                    }
                    #autoClickMenu .readRule {
                        padding: 5px;
                        border: 1px solid rgb(80, 80, 80);
                        border-radius: 3px;
                        margin-bottom: 5px;
                    }
                    #autoClickMenu .headerContainer {
                        display: flex;
                        justify-content: space-between;
                        align-items: center;
                        margin-bottom: 10px;
                    }
                    #autoClickMenu .closeButton {
                        width: auto;
                        padding: 5px 10px;
                        margin: 0;
                    }
                </style>
                <div id="autoClickMenu">
                    <div class="headerContainer">
                        <h3>${i18n.title}</h3>
                        <button id="closeMenu" class="closeButton">✕</button>
                    </div>
                    <div id="rulesList"></div>
                    <h4>${i18n.addRuleSection}</h4>
                    <label>${i18n.ruleName}</label>
                    <input type="text" id="ruleName" placeholder="${i18n.ruleNamePlaceholder}">
                    <label>${i18n.urlPattern}</label>
                    <input type="text" id="urlPattern" placeholder="${i18n.urlPatternPlaceholder}">
                    <label>${i18n.selectorType}</label>
                    <select id="selectorType">
                        <option value="css">CSS</option>
                        <option value="xpath">XPath</option>
                    </select>
                    <label>${i18n.selector}</label>
                    <input type="text" id="selector" placeholder="${i18n.selectorPlaceholder}">
                    <label>${i18n.nthElement}</label>
                    <input type="number" id="nthElement" min="1" value="1">
                    <label>${i18n.clickDelay}</label>
                    <input type="number" id="clickDelay" min="50" value="10000">
                    <button id="addRule" style="margin-top: 10px;">${i18n.addRule}</button>
                </div>
            `;
            document.body.appendChild(menu);

            this.updateRulesElement();

            document.getElementById('addRule').addEventListener('click', () => {
                const newRule = {
                    ruleName: document.getElementById('ruleName').value || `規則 ${this.ruleManager.clickRules.rules.length + 1}`,
                    urlPattern: document.getElementById('urlPattern').value,
                    selectorType: document.getElementById('selectorType').value,
                    selector: document.getElementById('selector').value,
                    nthElement: parseInt(document.getElementById('nthElement').value) || 1,
                    clickDelay: parseInt(document.getElementById('clickDelay').value) || 200
                };
                if (!this.validateRule(newRule)) return;
                this.ruleManager.addRule(newRule);
                this.updateRulesElement();
                this.clickTaskManager.clearAutoClicks();
                this.clickTaskManager.runAutoClicks();
                document.getElementById('ruleName').value = '';
                document.getElementById('urlPattern').value = '';
                document.getElementById('selector').value = '';
                document.getElementById('nthElement').value = '1';
                document.getElementById('clickDelay').value = '200';
            });

            document.getElementById('closeMenu').addEventListener('click', () => {
                menu.remove();
            });
        }

        // 更新規則列表(僅顯示當前網址符合的規則)
        updateRulesElement() {
            const rulesList = document.getElementById('rulesList');
            const i18n = this.i18n[this.getLanguage()];
            rulesList.innerHTML = `<h4>${i18n.matchingRules}</h4>`;
            const currentUrl = window.location.href;
            const matchingRules = this.ruleManager.clickRules.rules.filter(rule => {
                try {
                    return new RegExp(rule.urlPattern).test(currentUrl);
                } catch (e) {
                    console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的正則表達式無效:`, rule.urlPattern);
                    return false;
                }
            });

            if (matchingRules.length === 0) {
                rulesList.innerHTML += `<p>${i18n.noMatchingRules}</p>`;
                return;
            }

            matchingRules.forEach((rule) => {
                const ruleIndex = this.ruleManager.clickRules.rules.indexOf(rule);
                const ruleDiv = this.createRuleElement(rule, ruleIndex);
                rulesList.appendChild(ruleDiv);

                document.getElementById(`ruleHeader${ruleIndex}`).addEventListener('click', () => {
                    const details = document.getElementById(`readRule${ruleIndex}`);
                    details.style.display = details.style.display === 'none' ? 'block' : 'none';
                });

                document.getElementById(`updateRule${ruleIndex}`).addEventListener('click', () => {
                    const updatedRule = {
                        ruleName: document.getElementById(`updateRuleName${ruleIndex}`).value || `規則 ${ruleIndex + 1}`,
                        urlPattern: document.getElementById(`updateUrlPattern${ruleIndex}`).value,
                        selectorType: document.getElementById(`updateSelectorType${ruleIndex}`).value,
                        selector: document.getElementById(`updateSelector${ruleIndex}`).value,
                        nthElement: parseInt(document.getElementById(`updateNthElement${ruleIndex}`).value) || 1,
                        clickDelay: parseInt(document.getElementById(`updateClickDelay${ruleIndex}`).value) || 200
                    };
                    if (!this.validateRule(updatedRule)) return;
                    this.ruleManager.updateRule(ruleIndex, updatedRule);
                    this.updateRulesElement();
                    this.clickTaskManager.clearAutoClicks();
                    this.clickTaskManager.runAutoClicks();
                });

                document.getElementById(`deleteRule${ruleIndex}`).addEventListener('click', () => {
                    this.ruleManager.deleteRule(ruleIndex);
                    this.updateRulesElement();
                    this.clickTaskManager.clearAutoClicks();
                    this.clickTaskManager.runAutoClicks();
                });
            });
        }

        // 設置 URL 變更監聽器
        setupUrlChangeListener() {
            const oldPushState = history.pushState;
            history.pushState = function pushState() {
                const ret = oldPushState.apply(this, arguments);
                window.dispatchEvent(new Event('pushstate'));
                window.dispatchEvent(new Event('locationchange'));
                return ret;
            };

            const oldReplaceState = history.replaceState;
            history.replaceState = function replaceState() {
                const ret = oldReplaceState.apply(this, arguments);
                window.dispatchEvent(new Event('replacestate'));
                window.dispatchEvent(new Event('locationchange'));
                return ret;
            };

            window.addEventListener('popstate', () => {
                window.dispatchEvent(new Event('locationchange'));
            });

            window.addEventListener('locationchange', () => {
                this.clickTaskManager.clearAutoClicks();
                this.clickTaskManager.runAutoClicks();
            });
        }
    }

class ClickTaskManager {
    ruleManager;
    intervalIds = {};

    constructor(ruleManager) {
        this.ruleManager = ruleManager;
        this.runAutoClicks();
    }

    // 清除所有自動點擊任務
    clearAutoClicks() {
        Object.keys(this.intervalIds).forEach(index => {
            clearInterval(this.intervalIds[index]);
            delete this.intervalIds[index];
        });
    }

    // 執行所有符合規則的自動點擊
    runAutoClicks() {
        this.ruleManager.clickRules.rules.forEach((rule, index) => {
            if (rule.urlPattern && rule.selector && !this.intervalIds[index]) {
                const intervalId = setInterval(() => {
                    const clicked = this.autoClick(rule, index);
                    if (clicked) {
                        clearInterval(this.intervalIds[index]);
                        delete this.intervalIds[index];
                    }
                }, rule.clickDelay || 200);
                this.intervalIds[index] = intervalId;
            } else if (!rule.urlPattern || !rule.selector) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 無效(索引 ${index}):缺少 urlPattern 或 selector`);
            }
        });
    }

    // 執行單條規則的自動點擊,並返回是否成功
    autoClick(rule, ruleIndex) {
        try {
            const urlRegex = new RegExp(rule.urlPattern);
            if (!urlRegex.test(window.location.href)) {
                return false;
            }

            const elements = this.getElements(rule.selectorType, rule.selector);
            if (elements.length === 0) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 未找到符合元素:`, rule.selector);
                return false;
            }

            if (rule.nthElement < 1 || rule.nthElement > elements.length) {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 的 nthElement 無效:${rule.nthElement},找到 ${elements.length} 個元素`);
                return false;
            }

            const targetElement = elements[rule.nthElement - 1];
            if (targetElement) {
                console.log(`${GM_info.script.name}: 規則 "${rule.ruleName}" 成功點擊元素:`, targetElement);
                targetElement.click();
                if (targetElement.tagName === "A" && targetElement.href) {
                    window.location.href = targetElement.href;
                }
                return true;
            } else {
                console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 未找到目標元素`);
                return false;
            }
        } catch (e) {
            console.warn(`${GM_info.script.name}: 規則 "${rule.ruleName}" 執行失敗:`, e);
            return false;
        }
    }

    // 根據選擇器類型獲取元素
    getElements(selectorType, selector) {
        try {
            if (selectorType === 'xpath') {
                const nodes = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
                const elements = [];
                for (let i = 0; i < nodes.snapshotLength; i++) {
                    elements.push(nodes.snapshotItem(i));
                }
                return elements;
            } else if (selectorType === 'css') {
                return Array.from(document.querySelectorAll(selector));
            }
            return [];
        } catch (e) {
            console.warn(`${GM_info.script.name}: 選擇器 "${selector}" 無效:`, e);
            return [];
        }
    }
}

const Shirisaku = new RuleManager();
const Yubisaku = new ClickTaskManager(Shirisaku);
const Mika = new WebElementHandler(Shirisaku, Yubisaku);
GM_registerMenuCommand(Mika.getMenuTitle(), () => Mika.createMenuElement());