// ==UserScript== // @name 管理系统自动化-本地 // @namespace xyzw-helper-auto // @version 0.1.0 // @description 在 tokens 与 game-features 页,自动循环每个角色执行:一键补差→启动服务→领取奖励→加钟→立即签到→一键答题。通过正则匹配名称并点击最近的可点击控件。 // @author you // @match http://localhost:3001/tokens // @match http://localhost:3001/game-features // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @run-at document-end // ==/UserScript== (function () { "use strict"; // -------------- 基础工具 -------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const nowStr = () => new Date().toLocaleTimeString("zh-CN"); // -------------- 运行日志 -------------- const LOG_KEY = "automation_logs"; const LOG_LIMIT_KEY = "automation_log_limit"; const ACTION_DELAY_KEY = "automation_action_delay_ms"; const TASKS_KEY = "automation_tasks_config"; const TASKS_UI_OPEN_KEY = "automation_tasks_ui_open"; const TASK_PROGRESS_KEY = "automation_tasks_progress"; const RESTART_INTERVAL_KEY = "automation_restart_interval_minutes"; const PAGE_READY_DELAY_MS = 2000; const CONFIG_STORE_KEY = "automation_config_store_v1"; const DEFAULT_PROFILE_NAME = "default"; let configCache = null; let configManagerOpen = false; function clampNumber(value, min, max, fallback) { const num = Number(value); if (!Number.isFinite(num)) return fallback; if (min !== undefined && num < min) return min; if (max !== undefined && num > max) return max; return num; } function deepClone(value) { try { return JSON.parse(JSON.stringify(value)); } catch (_) { return value; } } function defaultProfileSettings() { return { logLimit: 200, actionDelayMs: 3000, restartIntervalMinutes: 0, tasks: defaultTasksConfig(), tasksUiOpen: false, description: "", }; } const TASK_NAME_MAP = { dailyFix: "一键补差", answer: "一键答题", claimReward: "领取奖励", addClock: "加钟", signIn: "立即签到", startService: "启动/重启服务", }; function normalizeTasks(tasks) { const base = defaultTasksConfig(); const input = tasks && typeof tasks === "object" ? tasks : {}; const next = { ...base }; for (const key in base) { next[key] = !!input[key]; } return next; } function normalizeProfile(profile) { const base = defaultProfileSettings(); const input = profile && typeof profile === "object" ? profile : {}; const next = { ...base, ...deepClone(input), }; next.logLimit = clampNumber(next.logLimit, 20, 2000, base.logLimit); next.actionDelayMs = clampNumber( next.actionDelayMs, 0, 600000, base.actionDelayMs ); next.restartIntervalMinutes = clampNumber( next.restartIntervalMinutes, 0, 1440, base.restartIntervalMinutes ); next.tasks = normalizeTasks(next.tasks); next.tasksUiOpen = !!next.tasksUiOpen; if (typeof next.description !== "string") next.description = ""; return next; } function createEmptyStore() { const profile = normalizeProfile(null); const now = Date.now(); return { version: 1, active: DEFAULT_PROFILE_NAME, profiles: { [DEFAULT_PROFILE_NAME]: profile, }, createdAt: now, updatedAt: now, }; } function normalizeConfigStore(raw) { const base = createEmptyStore(); const input = raw && typeof raw === "object" ? raw : {}; const store = { version: Number.isFinite(input.version) ? input.version : base.version, active: typeof input.active === "string" && input.active.trim() ? input.active.trim() : base.active, profiles: {}, createdAt: Number.isFinite(input.createdAt) ? input.createdAt : base.createdAt, updatedAt: Number.isFinite(input.updatedAt) ? input.updatedAt : base.updatedAt, }; const rawProfiles = input.profiles && typeof input.profiles === "object" ? input.profiles : {}; for (const [name, profile] of Object.entries(rawProfiles)) { if (!name) continue; store.profiles[name] = normalizeProfile(profile); } if (!Object.keys(store.profiles).length) { store.profiles[DEFAULT_PROFILE_NAME] = normalizeProfile(null); } if (!store.profiles[store.active]) { store.active = Object.keys(store.profiles)[0] || DEFAULT_PROFILE_NAME; } return store; } function loadConfigStoreFromStorage() { try { const stored = gmGet(CONFIG_STORE_KEY, null); if (stored && typeof stored === "object" && stored.profiles) { return stored; } } catch (_) { } return null; } function migrateLegacySettings() { const store = createEmptyStore(); const profile = store.profiles[DEFAULT_PROFILE_NAME]; const legacyLogLimit = gmGet(LOG_LIMIT_KEY, null); if (legacyLogLimit != null) { profile.logLimit = clampNumber(legacyLogLimit, 20, 2000, profile.logLimit); } const legacyDelay = gmGet(ACTION_DELAY_KEY, null); if (legacyDelay != null) { profile.actionDelayMs = clampNumber( legacyDelay, 0, 600000, profile.actionDelayMs ); } const legacyInterval = gmGet(RESTART_INTERVAL_KEY, null); if (legacyInterval != null) { profile.restartIntervalMinutes = clampNumber( legacyInterval, 0, 1440, profile.restartIntervalMinutes ); } const legacyTasks = gmGet(TASKS_KEY, null); if (legacyTasks && typeof legacyTasks === "object") { profile.tasks = normalizeTasks(legacyTasks); } const legacyTasksUiOpen = gmGet(TASKS_UI_OPEN_KEY, null); if (legacyTasksUiOpen != null) { profile.tasksUiOpen = !!legacyTasksUiOpen; } store.updatedAt = Date.now(); return store; } function saveConfigStore(store) { configCache = normalizeConfigStore(store); configCache.updatedAt = Date.now(); gmSet(CONFIG_STORE_KEY, configCache); } function getConfigStore() { if (configCache) return configCache; let raw = loadConfigStoreFromStorage(); if (!raw) { raw = migrateLegacySettings(); } configCache = normalizeConfigStore(raw); saveConfigStore(configCache); return configCache; } function getActiveProfileName(store = getConfigStore()) { return store.active && store.profiles[store.active] ? store.active : DEFAULT_PROFILE_NAME; } function setActiveProfileName(name) { const store = getConfigStore(); const key = typeof name === "string" ? name.trim() : ""; if (!key || !store.profiles[key]) return false; if (store.active !== key) { store.active = key; saveConfigStore(store); } return true; } function getActiveProfile(store = getConfigStore()) { const name = getActiveProfileName(store); if (!store.profiles[name]) { store.profiles[name] = normalizeProfile(null); saveConfigStore(store); } return store.profiles[name]; } function updateActiveProfileConfig(updater) { const store = getConfigStore(); const name = getActiveProfileName(store); const current = getActiveProfile(store); const draft = { ...current, tasks: { ...current.tasks } }; const nextRaw = typeof updater === "function" ? updater(draft) || draft : { ...draft, ...(updater || {}) }; store.profiles[name] = normalizeProfile(nextRaw); saveConfigStore(store); return store.profiles[name]; } function listProfileNames() { const store = getConfigStore(); return Object.keys(store.profiles); } function ensureUniqueProfileName(name) { const trimmed = (name || "").trim(); if (!trimmed) return null; return trimmed; } function hasProfile(name) { const store = getConfigStore(); return !!store.profiles[name]; } function createProfile(name, baseProfile) { const store = getConfigStore(); const key = ensureUniqueProfileName(name); if (!key || store.profiles[key]) return false; const source = baseProfile && typeof baseProfile === "object" ? baseProfile : defaultProfileSettings(); store.profiles[key] = normalizeProfile(source); saveConfigStore(store); return true; } function deleteProfile(name) { const store = getConfigStore(); const key = ensureUniqueProfileName(name); if (!key || key === DEFAULT_PROFILE_NAME || !store.profiles[key]) { return false; } delete store.profiles[key]; if (store.active === key) { store.active = DEFAULT_PROFILE_NAME; if (!store.profiles[store.active]) { const fallback = Object.keys(store.profiles)[0]; store.active = fallback || DEFAULT_PROFILE_NAME; } } saveConfigStore(store); return true; } function renameProfile(oldName, newName) { const store = getConfigStore(); const from = ensureUniqueProfileName(oldName); const to = ensureUniqueProfileName(newName); if ( !from || !to || from === to || !store.profiles[from] || store.profiles[to] || from === DEFAULT_PROFILE_NAME ) { return false; } store.profiles[to] = store.profiles[from]; delete store.profiles[from]; if (store.active === from) { store.active = to; } saveConfigStore(store); return true; } function summarizeTasksForProfile(profile) { if (!profile) return "无"; const enabled = Object.entries(profile.tasks || {}) .filter(([, flag]) => flag) .map(([key]) => TASK_NAME_MAP[key] || key); return enabled.length ? enabled.join("、") : "无"; } function isConfigManagerOpen() { return configManagerOpen; } function setConfigManagerOpen(v) { configManagerOpen = !!v; } function toggleConfigManagerPanel() { setConfigManagerOpen(!isConfigManagerOpen()); updatePanel(); } function openConfigManager() { setConfigManagerOpen(true); updatePanel(); notify("已打开配置管理,请在面板中操作。", "info"); } function handleConfigUse(name) { if (!name || !hasProfile(name)) { alert(`配置「${name}」不存在`); return; } if (getActiveProfileName() === name) { return; } if (setActiveProfileName(name)) { notify(`已切换到配置「${name}」`, "success"); updatePanel(); } } function handleConfigRename(name) { if (!name || !hasProfile(name)) { alert(`配置「${name}」不存在`); return; } if (name === DEFAULT_PROFILE_NAME) { alert("默认配置不可重命名"); return; } const input = prompt("输入新的配置名称", name); if (input == null) return; const trimmed = input.trim(); if (!trimmed) { alert("名称不能为空"); return; } if (hasProfile(trimmed)) { alert(`配置「${trimmed}」已存在`); return; } if (renameProfile(name, trimmed)) { notify(`已将配置「${name}」重命名为「${trimmed}」`, "success"); updatePanel(); } else { alert("重命名失败"); } } function handleConfigDelete(name) { if (!name || !hasProfile(name)) { alert(`配置「${name}」不存在`); return; } if (name === DEFAULT_PROFILE_NAME) { alert("默认配置不可删除"); return; } if (!confirm(`确定要删除配置「${name}」吗?该操作不可恢复。`)) { return; } if (deleteProfile(name)) { notify(`已删除配置「${name}」`, "success"); updatePanel(); } else { alert("删除配置失败"); } } function handleConfigCreate() { const input = prompt("输入新配置名称"); if (input == null) return; const name = input.trim(); if (!name) { alert("名称不能为空"); return; } if (hasProfile(name)) { alert(`配置「${name}」已存在`); return; } const useCurrent = confirm("是否复制当前配置?选择“取消”则使用默认配置。"); const base = useCurrent ? deepClone(getActiveProfile()) : defaultProfileSettings(); if (!createProfile(name, base)) { alert("创建配置失败"); return; } setActiveProfileName(name); notify(`已创建并切换到配置「${name}」`, "success"); updatePanel(); } function renderConfigList(listEl) { if (!listEl) return; const store = getConfigStore(); const active = getActiveProfileName(store); const names = listProfileNames(); while (listEl.firstChild) listEl.removeChild(listEl.firstChild); if (!names.length) { const empty = document.createElement("div"); empty.className = "config-empty"; empty.textContent = "暂无配置"; listEl.appendChild(empty); return; } names.forEach((name) => { const profile = store.profiles[name]; const item = document.createElement("div"); item.className = "config-item"; item.dataset.profileName = name; if (name === active) item.classList.add("active"); const header = document.createElement("div"); header.className = "config-item-header"; const title = document.createElement("span"); title.textContent = name; header.appendChild(title); if (name === active) { const badge = document.createElement("span"); badge.className = "config-badge"; badge.textContent = "当前"; header.appendChild(badge); } const meta = document.createElement("div"); meta.className = "config-item-meta"; meta.textContent = `任务:${summarizeTasksForProfile(profile)}|等待:${profile.actionDelayMs}ms|间隔:${profile.restartIntervalMinutes}分|日志:${profile.logLimit}`; const buttons = document.createElement("div"); buttons.className = "config-item-buttons"; const useBtn = document.createElement("button"); useBtn.type = "button"; useBtn.dataset.configAction = "use"; useBtn.dataset.profileName = name; useBtn.textContent = name === active ? "已启用" : "启用"; if (name === active) useBtn.disabled = true; const renameBtn = document.createElement("button"); renameBtn.type = "button"; renameBtn.dataset.configAction = "rename"; renameBtn.dataset.profileName = name; renameBtn.textContent = "重命名"; if (name === DEFAULT_PROFILE_NAME) renameBtn.disabled = true; const deleteBtn = document.createElement("button"); deleteBtn.type = "button"; deleteBtn.dataset.configAction = "delete"; deleteBtn.dataset.profileName = name; deleteBtn.textContent = "删除"; if (name === DEFAULT_PROFILE_NAME) deleteBtn.disabled = true; buttons.append(useBtn, renameBtn, deleteBtn); item.append(header, meta, buttons); listEl.appendChild(item); }); } function getLogLimit() { const profile = getActiveProfile(); return profile?.logLimit ?? defaultProfileSettings().logLimit; } function setLogLimit(n) { const v = parseInt(n, 10); if (!Number.isFinite(v) || v <= 0) return false; updateActiveProfileConfig({ logLimit: clampNumber(v, 20, 2000, 200) }); return true; } // 动作等待配置(默认 3000ms,可自定义) function getActionDelayMs() { const profile = getActiveProfile(); const v = parseInt(profile?.actionDelayMs, 10); if (!Number.isFinite(v) || v < 0) return defaultProfileSettings().actionDelayMs; return Math.min(Math.max(v, 0), 600000); // 0ms ~ 10分钟 } function setActionDelayMs(n) { const v = parseInt(n, 10); if (!Number.isFinite(v) || v < 0) return false; updateActiveProfileConfig({ actionDelayMs: clampNumber(v, 0, 600000, defaultProfileSettings().actionDelayMs), }); return true; } async function waitBetweenSteps() { const ms = getActionDelayMs(); if (ms > 0) await sleep(ms); } // 下次启动间隔配置(默认 0 分钟,可自定义) function getRestartIntervalMinutes() { const profile = getActiveProfile(); const v = parseInt(profile?.restartIntervalMinutes, 10); if (!Number.isFinite(v) || v < 0) { return defaultProfileSettings().restartIntervalMinutes; } return Math.min(Math.max(v, 0), 1440); // 0分钟 ~ 24小时 } function setRestartIntervalMinutes(n) { const v = parseInt(n, 10); if (!Number.isFinite(v) || v < 0) return false; updateActiveProfileConfig({ restartIntervalMinutes: clampNumber( v, 0, 1440, defaultProfileSettings().restartIntervalMinutes ), }); return true; } async function waitForRestartInterval() { const minutes = getRestartIntervalMinutes(); if (minutes > 0) { const ms = minutes * 60 * 1000; const nextTime = new Date(Date.now() + ms); const nextTimeStr = nextTime.toLocaleTimeString("zh-CN"); notify(`等待下次启动间隔 ${minutes} 分钟,将在 ${nextTimeStr} 开始下一轮...`, "info"); await sleep(ms); } } // 任务选择配置(默认全启用) function defaultTasksConfig() { return { dailyFix: true, // 一键补差 answer: true, // 一键答题 claimReward: true, // 领取奖励 addClock: true, // 加钟 signIn: true, // 立即签到 startService: true, // 启动/重启服务 }; } function getTasksConfig() { const profile = getActiveProfile(); const cfg = profile?.tasks && typeof profile.tasks === "object" ? profile.tasks : defaultTasksConfig(); return { ...defaultTasksConfig(), ...deepClone(cfg) }; } function setTasksConfig(cfg) { const base = defaultTasksConfig(); const next = { ...base }; for (const k in base) { next[k] = !!(cfg && typeof cfg === "object" && cfg[k]); } updateActiveProfileConfig({ tasks: next }); return next; } function isTasksUIOpen() { const profile = getActiveProfile(); return !!profile?.tasksUiOpen; } function setTasksUIOpen(v) { updateActiveProfileConfig({ tasksUiOpen: !!v }); } // 任务进度记录 // 状态:pending | ok | fail | skip function defaultTaskProgress(cfg) { const base = { dailyFix: "skip", answer: "skip", claimReward: "skip", addClock: "skip", signIn: "skip", startService: "skip", }; const c = cfg || getTasksConfig(); const res = { ...base }; for (const k in base) res[k] = c[k] ? "pending" : "skip"; return res; } function getTaskProgress() { const p = gmGet(TASK_PROGRESS_KEY, null); if (!p || typeof p !== "object") return defaultTaskProgress(); // 兼容缺失字段 return { ...defaultTaskProgress(), ...p }; } function setTaskProgress(patch) { const cur = getTaskProgress(); const next = { ...cur, ...(patch || {}) }; gmSet(TASK_PROGRESS_KEY, next); try { updatePanel(); } catch (_) { } return next; } function clearTaskProgress() { gmSet(TASK_PROGRESS_KEY, defaultTaskProgress()); try { updatePanel(); } catch (_) { } } function initTaskProgressFromConfig() { const next = defaultTaskProgress(); gmSet(TASK_PROGRESS_KEY, next); return next; } function summarizeTaskProgress() { const p = getTaskProgress(); const order = [ ["dailyFix", "补差"], ["answer", "答题"], ["claimReward", "领奖"], ["addClock", "加钟"], ["signIn", "签到"], ["startService", "启动"], ]; const icon = (s) => s === "ok" ? "✓" : s === "fail" ? "✗" : s === "skip" ? "—" : "…"; let okCnt = 0, totalCnt = 0; order.forEach(([k]) => { if (p[k] !== "skip") totalCnt += 1; if (p[k] === "ok") okCnt += 1; }); const parts = order.map(([k, name]) => `${name}${icon(p[k])}`); return { text: parts.join(" "), okCnt, totalCnt }; } function getLogs() { const arr = gmGet(LOG_KEY, []); return Array.isArray(arr) ? arr : []; } function setLogs(arr) { gmSet(LOG_KEY, Array.isArray(arr) ? arr : []); } function clearLogs() { setLogs([]); } function addLog(type, message) { const limit = getLogLimit(); const list = getLogs(); const item = { ts: Date.now(), time: nowStr(), type: String(type || "info"), message: String(message || ""), }; list.push(item); if (list.length > limit) { list.splice(0, list.length - limit); } setLogs(list); // 实时刷新面板日志视图(若存在) try { renderLogsToPanel(); } catch (_) { } } function notify(text, type = "info") { // 仅写入控制台与面板日志,不触发浏览器通知 console.log(`[自动化][${nowStr()}] ${text}`); addLog(type, text); } function gmGet(key, defVal) { try { if (typeof GM_getValue === "function") return GM_getValue(key, defVal); } catch (_) { } try { const raw = localStorage.getItem("__tm__" + key); return raw ? JSON.parse(raw) : defVal; } catch (_) { return defVal; } } function gmSet(key, val) { try { if (typeof GM_setValue === "function") return GM_setValue(key, val); } catch (_) { } try { localStorage.setItem("__tm__" + key, JSON.stringify(val)); } catch (_) { } } function isVisible(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return ( rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0" ); } function isClickable(el) { if (!el || !isVisible(el)) return false; const style = getComputedStyle(el); const tag = (el.tagName || "").toLowerCase(); if (tag === "button") return !el.disabled; if (tag === "input" && /^(button|submit)$/i.test(el.type)) return !el.disabled; if (tag === "a" && (el.href || el.getAttribute("href"))) return true; if (el.getAttribute && el.getAttribute("role") === "button") return true; if ( el.classList && (el.classList.contains("action-button") || el.classList.contains("execute-button")) ) { return !el.disabled; } if ( style.pointerEvents !== "none" && (el.onclick || el.getAttribute("onclick")) ) return true; const innerBtn = el.querySelector && el.querySelector("button"); if (innerBtn && isVisible(innerBtn) && !innerBtn.disabled) return true; return false; } function findNearestClickableFrom(el, root = document.body) { let cur = el; const excludeSelector = "#automation-control-panel"; const isInExcluded = (el2) => { try { return !!(el2 && el2.closest && el2.closest(excludeSelector)); } catch (_) { return false; } }; if (isInExcluded(cur)) return null; for ( let depth = 0; depth < 6 && cur && cur !== root && cur !== document.body; depth++ ) { if (isClickable(cur)) return cur; if (cur.querySelector) { const found = cur.querySelector( 'button, a[href], [role="button"], .action-button, .execute-button, .n-button, .n-button button' ); if (found && !isInExcluded(found) && isClickable(found)) return found; } cur = cur.parentElement; } if (el.parentElement && !isInExcluded(el.parentElement)) { const candidates = el.parentElement.querySelectorAll( 'button, a[href], [role="button"], .action-button, .execute-button, .n-button, .n-button button' ); for (const c of candidates) { if (!isInExcluded(c) && isClickable(c)) return c; } } return null; } function findClickableByText(pattern, root = document.body) { const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern, "i"); const excludeSelector = "#automation-control-panel"; const isInExcluded = (el) => { try { return !!(el && el.closest && el.closest(excludeSelector)); } catch (_) { return false; } }; const isTextNodeVisible = (nodeEl) => { try { let el = nodeEl; let hops = 0; while (el && el !== document.body && hops++ < 20) { const style = getComputedStyle(el); if ( style.display === "none" || style.visibility === "hidden" || style.opacity === "0" ) return false; el = el.parentElement; } return true; } catch (_) { return true; } }; // 1) 按钮/可点击控件文本直配 const clickableSelectors = 'button, a[href], [role="button"], .action-button, .execute-button, .n-button, .n-button button'; const direct = root.querySelectorAll(clickableSelectors); for (const btn of direct) { if (isInExcluded(btn)) continue; const text = (btn.innerText || btn.textContent || "").trim(); if (regex.test(text) && isClickable(btn)) return btn; } // 2) 文本节点匹配 -> 邻近可点击控件 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const s = (node.nodeValue || "").trim(); if (!s) return NodeFilter.FILTER_SKIP; const parent = node.parentElement; if (parent && isInExcluded(parent)) return NodeFilter.FILTER_SKIP; if (parent && !isTextNodeVisible(parent)) return NodeFilter.FILTER_SKIP; return regex.test(s) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); let node; while ((node = walker.nextNode())) { const parent = node.parentElement; if (isInExcluded(parent)) continue; const candidate = findNearestClickableFrom(parent, root); if (candidate) return candidate; } return null; } async function clickByText(pattern, opts = {}) { const { retries = 40, interval = 500, waitAfter = 600 } = opts; for (let i = 0; i < retries; i++) { if (!isRunning()) return false; const btn = findClickableByText(pattern); if (btn) { try { btn.scrollIntoView({ behavior: "smooth", block: "center" }); await sleep(200 + Math.random() * 150); const innerBtn = btn.querySelector && btn.querySelector("button"); (innerBtn || btn).click(); await sleep(waitAfter); return true; } catch (e) { } } await sleep(interval); } return false; } // 在限定超时时间内尝试点击文本按钮(默认5秒) async function clickByTextWithTimeout( pattern, timeoutMs = 5000, intervalMs = 250, waitAfterMs = 600 ) { const retries = Math.max(1, Math.floor(timeoutMs / intervalMs)); return clickByText(pattern, { retries, interval: intervalMs, waitAfter: waitAfterMs, }); } function waitForText( pattern, { root = document.body, timeout = 120000 } = {} ) { const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern, "i"); return new Promise((resolve, reject) => { const initial = ( root.innerText || root.textContent || "" ).toString(); if (regex.test(initial)) return resolve(true); const obs = new MutationObserver(() => { const txt = ( root.innerText || root.textContent || "" ).toString(); if (regex.test(txt)) { clearTimeout(timer); obs.disconnect(); resolve(true); } }); obs.observe(root, { childList: true, subtree: true, characterData: true, }); const timer = setTimeout(() => { obs.disconnect(); reject(new Error("等候文本超时: " + regex)); }, timeout); }); } // 等待指定正则文本对应的可点击控件出现 async function waitForClickableByText( pattern, { timeout = 120000, interval = 400 } = {} ) { const start = Date.now(); while (Date.now() - start < timeout) { if (!isRunning()) return false; const btn = findClickableByText(pattern); if (btn && isClickable(btn)) return true; await sleep(interval); } return false; } // 等待文本(排除指定容器中的文本,例如面板日志),默认排除自动化面板 function waitForTextExcluding( pattern, { root = document.body, timeout = 120000, exclude = ["#automation-control-panel"], } = {} ) { const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern, "i"); const excludeSelector = Array.isArray(exclude) ? exclude.join(",") : exclude || ""; const hasMatch = () => { try { const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const s = (node.nodeValue || "").trim(); if (!s) return NodeFilter.FILTER_SKIP; if ( excludeSelector && node.parentElement && node.parentElement.closest && node.parentElement.closest(excludeSelector) ) { return NodeFilter.FILTER_SKIP; } return regex.test(s) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, } ); return !!walker.nextNode(); } catch (_) { // 兜底:如果出错则不匹配 return false; } }; return new Promise((resolve, reject) => { if (hasMatch()) return resolve(true); const obs = new MutationObserver(() => { if (hasMatch()) { clearTimeout(timer); obs.disconnect(); resolve(true); } }); obs.observe(root, { childList: true, subtree: true, characterData: true, }); const timer = setTimeout(() => { obs.disconnect(); reject(new Error("等候文本超时: " + regex)); }, timeout); }); } async function waitForConnected({ timeout = 20000 } = {}) { try { await waitForText(/已连接/, { timeout }); await sleep(800); return true; } catch { return false; } } // -------------- 状态管理 -------------- const STATE_KEY = "automationState"; function getState() { return gmGet(STATE_KEY, { running: false, total: 0, current: 0, }); } function setState(s) { gmSet(STATE_KEY, s); } function clearState() { setState({ running: false, total: 0, current: 0 }); } function resetQueueProgress() { const st = getState(); const next = { ...st, current: 0, currentIndex: 0, currentRoleName: null, }; setState(next); try { updatePanel(); } catch (_) { } notify("已重置队列进度,当前索引回到 0", "success"); } function isRunning() { try { const s = getState(); return !!(s && s.running); } catch (_) { return false; } } function updateState(patch) { try { const cur = getState() || {}; const next = { ...cur, ...patch }; setState(next); return next; } catch (_) { setState(patch || {}); return patch || {}; } } // -------------- 页面判断 -------------- function isTokensPage() { return location.pathname.replace(/\/+$/, "") === "/tokens"; } function isFeaturesPage() { return location.pathname.replace(/\/+$/, "") === "/game-features"; } // -------------- tokens 页:角色卡片 -------------- function getTokenCards() { return Array.from(document.querySelectorAll(".token-card")); } function probeCurrentRoleNameFromDom() { try { const el = document.querySelector(".page-subtitle"); const t = (el?.innerText || "").trim(); if (t && !/未选择Token/.test(t)) return t; } catch (_) { } return null; } function getCardName(card) { try { return ( card.querySelector(".token-name")?.innerText || card.innerText || "" ).trim(); } catch (_) { return ""; } } async function waitForActiveCardByName( targetName, { timeout = 8000 } = {} ) { const start = Date.now(); while (Date.now() - start < timeout) { if (!isRunning()) return false; const active = document.querySelector( ".token-card.active .token-name" ); const name = (active?.innerText || "").trim(); if (name && targetName && name === targetName) return true; await sleep(200); } return false; } async function waitForTokenCards({ timeout = 15000 } = {}) { const start = Date.now(); while (Date.now() - start < timeout) { if (!isRunning()) return []; const cards = getTokenCards(); if (cards.length > 0) return cards; await sleep(300); } return []; } async function processNextOnTokensPage() { const st = getState(); if (!st.running) return; const cards = await waitForTokenCards(); if (!isRunning()) return; // 持久化总数到状态,便于面板回显 if (cards && cards.length) updateState({ total: cards.length }); if (!cards.length) { notify("未找到任何角色卡片,停止。", "error"); clearState(); return; } if (st.current >= cards.length) { notify("所有角色已处理完成,稍后将重新开始下一轮。", "success"); const loopDelay = Math.max(0, getActionDelayMs()); updateState({ running: true, total: cards.length, current: 0, currentIndex: 0, currentRoleName: null, }); try { updatePanel(); } catch (_) { } if (!isRunning()) return; // 应用下次启动间隔延迟 await waitForRestartInterval(); if (!isRunning()) return; if (loopDelay > 0) await sleep(loopDelay); if (!isRunning()) return; return processNextOnTokensPage(); } const card = cards[st.current]; try { if (!isRunning()) return; const targetName = getCardName(card); updateState({ currentRoleName: targetName, currentIndex: st.current, }); card.scrollIntoView({ behavior: "smooth", block: "center" }); await sleep(200); if (!isRunning()) return; card.click(); await sleep(600); // 等待该卡片成为 active,确保已选中正确角色 const ok = await waitForActiveCardByName(targetName, { timeout: 8000, }); if (!ok) { // 再尝试一次点击 notify( `未检测到已选中目标卡片「${targetName}」,重试点击一次...`, "warning" ); card.click(); await sleep(800); await waitForActiveCardByName(targetName, { timeout: 5000 }); } } catch (e) { notify( `点击第 ${st.current + 1} 个角色失败,尝试继续。`, "warning" ); } if (!isRunning()) return; notify( `前往任务页处理:第 ${st.current + 1}/${cards.length} 个角色`, "info" ); await sleep(500); if (isRunning()) location.assign(`${location.origin}/game-features`); } // -------------- 任务页:执行流程 -------------- async function runTaskFlowOnFeaturesPage() { const st = getState(); if (!st.running) return; await waitForConnected({ timeout: 20000 }); if (!isRunning()) return; const tasks = getTasksConfig(); // 初始化本角色任务进度 initTaskProgressFromConfig(); // 尝试从页面头部回显当前角色名称 if (!st.currentRoleName) { const n = probeCurrentRoleNameFromDom(); if (n) updateState({ currentRoleName: n }); } // 1. 一键补差(点击后等待日志:最终角色信息刷新完成;排除面板日志) if (tasks.dailyFix) { if (!isRunning()) return; notify("开始:一键补差", "info"); const fixClicked = await clickByText(/一键补差/); if (fixClicked) { // 点击后一秒再开始任务“停止”检查与按钮恢复检查 await sleep(1000); if (!isRunning()) return; let ok = false; try { await waitForTextExcluding(/最终角色信息刷新完成/, { timeout: 18000, exclude: ["#automation-control-panel"], }); ok = true; } catch (e) { ok = false; } if (ok) { notify( "一键补差完成(检测到“最终角色信息刷新完成”)", "success" ); setTaskProgress({ dailyFix: "ok" }); } else { notify( "等待“最终角色信息刷新完成”超时,继续后续步骤。", "warning" ); // 已经点击但未检测到完成,也视为已尝试成功 setTaskProgress({ dailyFix: "ok" }); } } else { notify("未找到“一键补差”按钮,继续后续步骤。", "warning"); setTaskProgress({ dailyFix: "fail" }); } await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ dailyFix: "skip" }); } // 2. 一键答题 if (tasks.answer) { if (!isRunning()) return; notify("尝试:一键答题", "info"); const ok = await clickByTextWithTimeout(/一键答题/, 2000, 250, 300); setTaskProgress({ answer: ok ? "ok" : "fail" }); await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ answer: "skip" }); } // 3. 领取奖励 if (tasks.claimReward) { if (!isRunning()) return; notify("尝试:领取奖励", "info"); const ok = await clickByTextWithTimeout(/领取奖励/, 2000, 250, 300); setTaskProgress({ claimReward: ok ? "ok" : "fail" }); await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ claimReward: "skip" }); } // 4. 加钟 if (tasks.addClock) { if (!isRunning()) return; notify("尝试:加钟", "info"); const ok = await clickByTextWithTimeout(/加钟/, 2000, 250, 300); setTaskProgress({ addClock: ok ? "ok" : "fail" }); await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ addClock: "skip" }); } // 5. 立即签到 if (tasks.signIn) { if (!isRunning()) return; // 检查是否已签到 const alreadySignedIn = findClickableByText(/已签到/); if (alreadySignedIn) { notify("检测到已签到,跳过立即签到任务", "info"); setTaskProgress({ signIn: "skip" }); } else { notify("尝试:立即签到", "info"); const ok = await clickByTextWithTimeout(/立即签到/, 2000, 250, 300); setTaskProgress({ signIn: ok ? "ok" : "fail" }); } await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ signIn: "skip" }); } // 6. 启动/重启服务 if (tasks.startService) { if (!isRunning()) return; notify("尝试:启动/重启服务", "info"); const ok = await clickByTextWithTimeout( /启动服务|重启服务/, 2000, 250, 300 ); setTaskProgress({ startService: ok ? "ok" : "fail" }); await waitBetweenSteps(); if (!isRunning()) return; } else { setTaskProgress({ startService: "skip" }); } // 末尾再等待一次,确保后续状态稳定 await waitBetweenSteps(); if (!isRunning()) return; const nextState = updateState({ current: st.current + 1 }); notify( `当前角色处理完成,返回 Token 管理页(下一位:${(nextState.current ?? st.current) + 1 }/${nextState.total ?? "-"})`, "info" ); // 根据需求:跳转到 tokens 页前固定等待 2 秒 if (!isRunning()) return; await sleep(PAGE_READY_DELAY_MS); if (!isRunning()) return; location.assign(`${location.origin}/tokens`); } // -------------- 启停与菜单 -------------- function startAllRoles() { const cards = getTokenCards(); const total = cards.length; if (!isTokensPage() || total === 0) { notify("请在 Token 管理页启动,且确保已有角色!", "warning"); return; } const prev = getState() || {}; let current = Number.isFinite(prev.current) ? prev.current : 0; // 边界保护:若上次索引超出范围,则从 0 开始 if (total <= 0) current = 0; else if (current >= total) current = 0; updateState({ running: true, total, current, currentRoleName: null, currentIndex: current, }); notify( `开始循环自动做任务,共 ${total} 个角色(从第 ${current + 1 } 个继续)`, "info" ); processNextOnTokensPage(); } function stopAll() { // 仅停止运行,保留队列与任务进度 updateState({ running: false }); notify("已停止(保留队列进度)。", "info"); } // 暂停(与停止一致:不清空进度,仅置为非运行态) function pauseRun() { updateState({ running: false }); notify("已暂停(保留队列进度)。", "info"); try { updatePanel(); } catch (_) { } } // 恢复(从上次队列索引继续) function resumeRun() { updateState({ running: true }); notify("已恢复(从上次进度继续)。", "success"); try { updatePanel(); } catch (_) { } if (isTokensPage()) { startAllRoles(); } else { location.assign(`${location.origin}/tokens`); } } try { GM_registerMenuCommand("开始:循环所有角色自动做任务", startAllRoles); GM_registerMenuCommand("停止:暂停运行", stopAll); GM_registerMenuCommand("设置日志最大行数", () => { const cur = getLogLimit(); const input = prompt("输入日志最大行数(20~2000)", String(cur)); if (input == null) return; if (setLogLimit(input)) { notify("日志最大行数已更新为 " + getLogLimit(), "success"); } else { notify("输入无效,未更新日志行数", "warning"); } }); GM_registerMenuCommand("清空运行日志", () => { clearLogs(); renderLogsToPanel(); notify("已清空运行日志", "success"); }); GM_registerMenuCommand("设置动作等待时间(ms)", () => { const cur = getActionDelayMs(); const input = prompt( "输入每步动作之间的等待时间(毫秒,0~600000)", String(cur) ); if (input == null) return; if (setActionDelayMs(input)) { notify( "动作等待时间已更新为 " + getActionDelayMs() + "ms", "success" ); updatePanel(); } else { notify("输入无效,未更新动作等待时间", "warning"); } }); GM_registerMenuCommand("设置下次启动间隔(分钟)", () => { const cur = getRestartIntervalMinutes(); const input = prompt( "输入下次启动间隔时间(分钟,0~1440)", String(cur) ); if (input == null) return; if (setRestartIntervalMinutes(input)) { notify( "下次启动间隔已更新为 " + getRestartIntervalMinutes() + " 分钟", "success" ); updatePanel(); } else { notify("输入无效,未更新下次启动间隔", "warning"); } }); GM_registerMenuCommand("配置管理", openConfigManager); } catch (_) { } // -------------- 管理面板(可折叠,10px 字体) -------------- const PANEL_ID = "automation-control-panel"; const PANEL_COLLAPSE_KEY = "automation_panel_collapsed"; function ensurePanelStyles() { const css = ` #${PANEL_ID} { position: fixed; right: 12px; bottom: 12px; z-index: 999999; font-size: 10px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial; color: #111; } #${PANEL_ID} .panel-wrap { background: rgba(255,255,255,.98); border: 1px solid #e5e7eb; box-shadow: 0 6px 18px rgba(0,0,0,.12); border-radius: 8px; overflow: hidden; min-width: 200px; } #${PANEL_ID} .panel-header { display:flex; align-items:center; justify-content:space-between; gap:6px; padding:6px 8px; background:#f3f4f6; border-bottom:1px solid #e5e7eb; } #${PANEL_ID} .panel-title { font-weight:600; } #${PANEL_ID} .panel-actions { display:flex; align-items:center; gap:6px; } #${PANEL_ID} button { font-size:10px; line-height:1; padding:4px 6px; border-radius:4px; border:1px solid #d1d5db; background:#fff; cursor:pointer; } #${PANEL_ID} button:hover { background:#f9fafb; } #${PANEL_ID} .collapse-btn { color:#374151; } #${PANEL_ID} .panel-body { padding:8px; display:grid; gap:6px; } #${PANEL_ID} .row { display:flex; align-items:center; justify-content:space-between; gap:8px; } #${PANEL_ID} .label { color:#6b7280; } #${PANEL_ID} .value { color:#111827; font-weight:600; } #${PANEL_ID} .tasks-config { display:grid; grid-template-columns: 1fr 1fr; gap:4px 8px; padding:6px; border:1px dashed #d1d5db; border-radius:6px; background:#fff; } #${PANEL_ID} .tasks-config label { display:flex; align-items:center; gap:6px; font-weight:500; } #${PANEL_ID} .tasks-actions { grid-column: 1 / -1; display:flex; gap:6px; justify-content:flex-end; margin-top:4px; } #${PANEL_ID} .config-manager { display:grid; gap:6px; padding:6px; border:1px dashed #d1d5db; border-radius:6px; background:#fff; } #${PANEL_ID} .config-list { display:flex; flex-direction:column; gap:4px; max-height:180px; overflow:auto; } #${PANEL_ID} .config-item { border:1px solid #e5e7eb; border-radius:6px; padding:6px; background:#fff; display:flex; flex-direction:column; gap:4px; } #${PANEL_ID} .config-item.active { border-color:#2563eb; box-shadow:0 0 0 1px rgba(37,99,235,.35); } #${PANEL_ID} .config-item-header { display:flex; align-items:center; justify-content:space-between; gap:6px; font-weight:600; } #${PANEL_ID} .config-item-meta { font-size:9px; color:#6b7280; } #${PANEL_ID} .config-item-buttons { display:flex; gap:4px; flex-wrap:wrap; } #${PANEL_ID} .config-item-buttons button { flex:0 0 auto; } #${PANEL_ID} .config-actions { display:flex; gap:6px; justify-content:flex-end; flex-wrap:wrap; } #${PANEL_ID} .config-badge { font-size:9px; color:#2563eb; background:rgba(37,99,235,.12); padding:2px 4px; border-radius:4px; } #${PANEL_ID} .config-empty { font-size:10px; color:#6b7280; text-align:center; padding:6px 0; } #${PANEL_ID} .log-box { max-height: 200px; overflow:auto; background:#f9fafb; border:1px solid #e5e7eb; border-radius:6px; padding:6px; font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace; line-height:1.3; } #${PANEL_ID} .log-item { margin:1px 0; white-space: pre-wrap; word-break: break-word; } #${PANEL_ID} .log-item.info { color:#111827; } #${PANEL_ID} .log-item.warn, #${PANEL_ID} .log-item.warning { color:#b45309; } #${PANEL_ID} .log-item.error { color:#b91c1c; } #${PANEL_ID} .log-item.success, #${PANEL_ID} .log-item.ok { color:#065f46; } @media (prefers-color-scheme: dark) { #${PANEL_ID} .panel-wrap { background: rgba(31,41,55,.98); border-color:#374151; color:#e5e7eb; } #${PANEL_ID} .panel-header { background:#111827; border-color:#374151; } #${PANEL_ID} .label { color:#9ca3af; } #${PANEL_ID} .value { color:#e5e7eb; } #${PANEL_ID} button { background:#111827; color:#e5e7eb; border-color:#374151; } #${PANEL_ID} button:hover { background:#1f2937; } #${PANEL_ID} .tasks-config { background:#0b1220; border-color:#374151; } #${PANEL_ID} .config-manager { background:#0b1220; border-color:#374151; } #${PANEL_ID} .config-item { background:#111827; border-color:#374151; } #${PANEL_ID} .config-item.active { border-color:#3b82f6; box-shadow:0 0 0 1px rgba(59,130,246,.35); } #${PANEL_ID} .config-item-meta { color:#9ca3af; } #${PANEL_ID} .config-empty { color:#9ca3af; } #${PANEL_ID} .config-badge { color:#bfdbfe; background:rgba(59,130,246,.25); } #${PANEL_ID} .log-box { background:#111827; border-color:#374151; } } `; try { if (typeof GM_addStyle === "function") GM_addStyle(css); } catch (_) { const style = document.createElement("style"); style.textContent = css; document.head.appendChild(style); } } function getCollapsed() { return !!gmGet(PANEL_COLLAPSE_KEY, false); } function setCollapsed(v) { gmSet(PANEL_COLLAPSE_KEY, !!v); } function currentPageTag() { if (isTokensPage()) return "tokens"; if (isFeaturesPage()) return "game-features"; return location.pathname.replace(/\/+$/, "") || "/"; } function renderPanel() { ensurePanelStyles(); let root = document.getElementById(PANEL_ID); if (!root) { root = document.createElement("div"); root.id = PANEL_ID; root.innerHTML = `