// ==UserScript== // @name ScriptCat 网页操作录制与批量自动化 // @namespace scriptcat-web-action-recorder // @version 3.0.0 // @description 支持网页操作录制、规则分组、快速检索、多选批量执行、变量和跨页面自动化。 // @author AI // @match http://*/* // @match https://*/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_openInTab // @license AI生成任意使用 // ==/UserScript== (function () { 'use strict'; /****************************************************************** * 一、存储键和默认设置 ******************************************************************/ const STORAGE = { RULES: 'SC_RECORDER_RULES_V2', SETTINGS: 'SC_RECORDER_SETTINGS_V2', DRAFT: 'SC_RECORDER_DRAFT_V2', COUNTERS: 'SC_RECORDER_VARIABLE_COUNTERS_V2', // 3.0 新增 TASK_STATE: 'SC_RECORDER_TASK_STATE_V3', EXEC_LOGS: 'SC_RECORDER_EXEC_LOGS', GROUPS: 'SC_RECORDER_GROUPS_V3', UI_STATE: 'SC_RECORDER_UI_STATE_V3', // 旧执行状态,用于升级清理 OLD_RUN_STATE: 'SC_RECORDER_RUN_STATE_V2', OLD_BATCH_STATE: 'SC_RECORDER_BATCH_STATE_V2' }; const DEFAULT_GROUP = ''; const DEFAULT_SETTINGS = { recordingMode: 'simple', variablePrefix: '{', variableSuffix: '}', stepDelay: 450, elementTimeout: 15000, recordPasswords: false, showFloatingButton: true, scrollIntoView: true, ignoreQueryForPageCheck: true, fixedVariables: [], // 新增:规则名称设置 ruleNameTemplate: '{title}', // 新增:文本框变量自动替换 autoReplaceInputs: false, inputVariableNames: [''], // 新增:静默保存 silentSave: false, // 新增:批量执行并发数 batchConcurrency: 5, // 新增:屏蔽页面弹窗 blockAlerts: true, // 新增:单步超时(毫秒) stepTimeout: 30000 }; const state = { initialized: false, recording: false, playing: false, paused: false, currentDraft: null, // 主控调度器(仅在主控页面有效) batchScheduler: null, // 批量子标签页防重定向循环保护 _lastBatchRedirect: 0, // 子标签页心跳上报定时器 _heartbeatTimer: null, recorderCleanups: [], scrollTimers: new Map(), pendingInputs: new Map(), pendingInputSequence: 0, managerHost: null, floatingHost: null, recordingIndicator: null, manager: { tab: 'rules', search: '', group: '__all__', selectedRuleIds: new Set(), viewMode: 'list' } }; /****************************************************************** * 二、存储兼容层 ******************************************************************/ function storeGet(key, defaultValue) { try { if (typeof GM_getValue === 'function') { return GM_getValue(key, defaultValue); } const value = localStorage.getItem(key); return value === null ? defaultValue : JSON.parse(value); } catch (error) { console.error('[网页录制器] 读取存储失败:', error); return defaultValue; } } function storeSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); } else { localStorage.setItem(key, JSON.stringify(value)); } } catch (error) { console.error('[网页录制器] 写入存储失败:', error); } } function storeDelete(key) { try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(key); } else { localStorage.removeItem(key); } } catch (error) { console.error('[网页录制器] 删除存储失败:', error); } } let originalAlert = null; let originalConfirm = null; let originalPrompt = null; function blockPageDialogs() { const settings = getSettings(); if (!settings.blockAlerts) { return; } if (!originalAlert) { originalAlert = window.alert; originalConfirm = window.confirm; originalPrompt = window.prompt; } window.alert = function() { console.log('[网页录制器] 拦截 alert:', arguments[0]); return undefined; }; window.confirm = function() { console.log('[网页录制器] 拦截 confirm:', arguments[0]); return true; }; window.prompt = function() { console.log('[网页录制器] 拦截 prompt:', arguments[0]); return arguments[1] || ''; }; } function restorePageDialogs() { if (originalAlert) { window.alert = originalAlert; window.confirm = originalConfirm; window.prompt = originalPrompt; } } function getSettings() { const saved = storeGet(STORAGE.SETTINGS, {}) || {}; return { ...DEFAULT_SETTINGS, ...saved, recordingMode: saved.recordingMode === 'detailed' ? 'detailed' : 'simple', fixedVariables: Array.isArray(saved.fixedVariables) ? saved.fixedVariables : [], ruleNameTemplate: String(saved.ruleNameTemplate || DEFAULT_SETTINGS.ruleNameTemplate), autoReplaceInputs: Boolean(saved.autoReplaceInputs), inputVariableNames: Array.isArray(saved.inputVariableNames) ? saved.inputVariableNames : DEFAULT_SETTINGS.inputVariableNames, silentSave: Boolean(saved.silentSave), batchConcurrency: Math.max(1, Math.min(20, Number(saved.batchConcurrency) || 5)), blockAlerts: saved.blockAlerts !== false, stepTimeout: Math.max(5000, Number(saved.stepTimeout) || 30000) }; } function saveSettings(settings) { storeSet(STORAGE.SETTINGS, { ...DEFAULT_SETTINGS, ...settings, recordingMode: settings.recordingMode === 'detailed' ? 'detailed' : 'simple', fixedVariables: Array.isArray(settings.fixedVariables) ? settings.fixedVariables : [], ruleNameTemplate: String(settings.ruleNameTemplate || '{title}'), autoReplaceInputs: Boolean(settings.autoReplaceInputs), inputVariableNames: Array.isArray(settings.inputVariableNames) ? settings.inputVariableNames : [''], silentSave: Boolean(settings.silentSave), batchConcurrency: Math.max(1, Math.min(20, Number(settings.batchConcurrency) || 5)), blockAlerts: settings.blockAlerts !== false, stepTimeout: Math.max(5000, Number(settings.stepTimeout) || 30000) }); } function normalizeRule(rule) { if (!rule || typeof rule !== 'object') { return null; } return { ...rule, id: rule.id || createId(), name: String(rule.name || '未命名规则'), group: String(rule.group || DEFAULT_GROUP), urlPattern: String(rule.urlPattern || '*'), recordingMode: rule.recordingMode === 'detailed' ? 'detailed' : 'simple', steps: Array.isArray(rule.steps) ? rule.steps : [], createdAt: Number(rule.createdAt) || Date.now(), updatedAt: Number(rule.updatedAt) || Date.now() }; } function getRules() { const rules = storeGet(STORAGE.RULES, []); if (!Array.isArray(rules)) { return []; } return rules .map(normalizeRule) .filter(Boolean); } function saveRules(rules) { storeSet( STORAGE.RULES, Array.isArray(rules) ? rules.map(normalizeRule).filter(Boolean) : [] ); } function extractRuleVariables(rule) { const settings = getSettings(); const prefix = settings.variablePrefix || '{'; const suffix = settings.variableSuffix || '}'; const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp( escapedPrefix + '([A-Za-z0-9_\u4e00-\u9fa5]+)' + escapedSuffix, 'g' ); const found = new Set(); const text = JSON.stringify(rule.steps || []); let match; while ((match = regex.exec(text)) !== null) { found.add(match[1]); } return Array.from(found).sort(); } function getGroups() { const stored = storeGet(STORAGE.GROUPS, []); const names = new Set(); if (Array.isArray(stored)) { for (const item of stored) { const name = String( typeof item === 'string' ? item : item?.name || '' ).trim(); if (name) { names.add(name); } } } for (const rule of getRules()) { const group = String(rule.group || '').trim(); if (group) { names.add(group); } } return Array.from(names).sort((a, b) => a.localeCompare(b, 'zh-CN') ); } function saveGroups(groups) { const normalized = Array.from( new Set( (groups || []) .map(item => String(item || '').trim()) .filter(Boolean) ) ).sort((a, b) => a.localeCompare(b, 'zh-CN')); storeSet(STORAGE.GROUPS, normalized); } function getTaskStorageKey() { // 子标签页使用独立存储键,避免 saveTaskState 覆盖心跳数据 if (state.batchTaskId) { return BATCH_TASK_PREFIX + state.batchTaskId + '_exec'; } return STORAGE.TASK_STATE; } function getTaskState() { const task = storeGet(getTaskStorageKey(), null); if (!task || typeof task !== 'object' || !task.active) { return null; } return task; } function saveTaskState(task) { storeSet(getTaskStorageKey(), { ...task, active: true, updatedAt: Date.now() }); } function clearTaskState() { storeDelete(getTaskStorageKey()); } /****************************************************************** * 批量执行调度器 v2(主控页面) * * 架构: * - 主控页面(管理面板所在标签页)维护规则队列和固定大小槽位数组 * - 通过心跳检测子标签页存活状态 * - 每个子标签页只执行一条规则 * - 分组名包含"验证码"的规则:执行完毕后保留标签页,只腾出槽位 ******************************************************************/ const BATCH_TASK_PREFIX = 'SC_RECORDER_BATCH_TASK_'; const BATCH_DONE_PREFIX = 'SC_RECORDER_BATCH_DONE_'; // 心跳轮询间隔(毫秒) const HEARTBEAT_POLL_INTERVAL = 2000; // 心跳超时阈值(毫秒):超过此时间无心跳则判定为超时 const HEARTBEAT_TIMEOUT = 30000; // 首次心跳等待时间(毫秒):新派发任务后等待标签页加载并首次上报心跳 const HEARTBEAT_INITIAL_WAIT = 15000; /** * 可中止信号对象,用于取消 waitForLocator 等轮询操作 */ function createCancelSignal() { let aborted = false; return { get aborted() { return aborted; }, abort() { aborted = true; } }; } /** * 创建主控调度器 */ function createBatchScheduler(ruleIds, overrideVariables) { const settings = getSettings(); const concurrency = Math.max(1, Math.min(20, Number(settings.batchConcurrency) || 5)); const rules = getRules(); const ruleMap = new Map(rules.map(r => [r.id, r])); const scheduler = { id: createId(), queue: ruleIds.filter(id => ruleMap.has(id)), slots: new Array(concurrency).fill(null), ruleMap: ruleMap, overrideVariables: overrideVariables || {}, total: 0, completed: 0, failed: 0, skipped: 0, captchaTabs: [], running: true, timerId: null }; scheduler.total = scheduler.queue.length; return scheduler; } /** * 调度器轮询:基于心跳检测任务状态 */ function schedulerTick(scheduler) { if (!scheduler || !scheduler.running) return; const now = Date.now(); // 1. 检查所有槽位的心跳状态 for (let i = 0; i < scheduler.slots.length; i++) { const slot = scheduler.slots[i]; if (!slot) continue; // 1a. 检查是否已完成(子标签页写入了完成记录) const done = storeGet(BATCH_DONE_PREFIX + slot.taskId, null); if (done) { const isCaptcha = Boolean(done.isCaptcha); if (done._skipped) { scheduler.skipped++; addLog(`已跳过:${slot.rule.name}`, 'warning', {taskId: slot.taskId, slot: i}); } else if (done.success) { scheduler.completed++; addLog(`完成:${slot.rule.name}${isCaptcha ? '(验证码组,标签已保留)' : ''}`, 'success', {taskId: slot.taskId, slot: i}); // 验证码组完成:记录到 captchaTabs 供 UI 展示 if (isCaptcha) { scheduler.captchaTabs.push({ taskId: slot.taskId, rule: slot.rule, slotIndex: i }); } } else { scheduler.failed++; addLog(`失败:${slot.rule.name}`, 'error', {taskId: slot.taskId, slot: i}); } storeDelete(BATCH_DONE_PREFIX + slot.taskId); storeDelete(BATCH_TASK_PREFIX + slot.taskId); scheduler.slots[i] = null; continue; } // 1b. 检查任务存储是否丢失 const taskData = storeGet(BATCH_TASK_PREFIX + slot.taskId, null); if (!taskData) { scheduler.failed++; addLog(`任务丢失:${slot.rule.name}(槽位${i + 1})`, 'error', {taskId: slot.taskId, slot: i}); scheduler.slots[i] = null; continue; } // 1c. 检测跳过信号(主控设置 _skip: true) if (taskData._skip) { scheduler.skipped++; addLog(`已跳过:${slot.rule.name}`, 'warning', {taskId: slot.taskId, slot: i}); storeDelete(BATCH_TASK_PREFIX + slot.taskId); storeDelete(BATCH_DONE_PREFIX + slot.taskId); scheduler.slots[i] = null; continue; } // 1d. 心跳超时检测 const lastHeartbeat = taskData._heartbeat || taskData.createdAt || slot.dispatchedAt; const elapsed = now - lastHeartbeat; const timeSinceDispatch = now - slot.dispatchedAt; if (timeSinceDispatch > HEARTBEAT_INITIAL_WAIT && elapsed > HEARTBEAT_TIMEOUT) { scheduler.failed++; addLog(`心跳超时(${Math.round(elapsed / 1000)}s):${slot.rule.name}(槽位${i + 1})`, 'error', {taskId: slot.taskId, slot: i}); storeDelete(BATCH_TASK_PREFIX + slot.taskId); storeDelete(BATCH_DONE_PREFIX + slot.taskId); scheduler.slots[i] = null; continue; } } // 2. 派发新任务到空闲槽位 for (let i = 0; i < scheduler.slots.length; i++) { if (scheduler.slots[i] !== null) continue; if (scheduler.queue.length === 0) continue; const ruleId = scheduler.queue.shift(); const rule = scheduler.ruleMap.get(ruleId); if (!rule) { scheduler.skipped++; continue; } const taskId = createId(); const isCaptchaRule = rule.group && rule.group.includes('验证码'); const batchTask = { taskId: taskId, ruleId: rule.id, stepIndex: 0, variableOverrides: scheduler.overrideVariables, currentVariables: null, active: true, _heartbeat: Date.now(), _captchaGroup: isCaptchaRule || false, createdAt: Date.now() }; storeSet(BATCH_TASK_PREFIX + taskId, batchTask); const startUrl = getRuleStartUrl(rule); if (!startUrl) { storeDelete(BATCH_TASK_PREFIX + taskId); scheduler.skipped++; addLog(`跳过(无起始URL):${rule.name}`, 'warning'); continue; } scheduler.slots[i] = { taskId: taskId, rule: rule, dispatchedAt: Date.now() }; const url = addTaskParamToUrl(startUrl, taskId); openNewTab(url, false); addLog(`派发 → 槽位${i + 1}:${rule.name}${isCaptchaRule ? '(验证码组)' : ''}`, 'info', {taskId, slot: i}); } // 3. 检查是否全部完成 const allSlotsEmpty = scheduler.slots.every(s => s === null); const queueEmpty = scheduler.queue.length === 0; if (allSlotsEmpty && queueEmpty) { scheduler.running = false; if (scheduler.timerId) { clearInterval(scheduler.timerId); scheduler.timerId = null; } if (scheduler.captchaTabs.length > 0) { addLog(`批量执行完成,已保留 ${scheduler.captchaTabs.length} 个验证码标签页。`, 'success'); } const msg = `批量执行完成:成功 ${scheduler.completed},失败 ${scheduler.failed},跳过 ${scheduler.skipped},共 ${scheduler.total} 条。`; showToast(msg, 'success', 8000); addLog(msg, 'success'); if (state.managerHost) { renderManager(); } return; } if (state.managerHost) { renderManager(); } } function startBatchScheduler(scheduler) { scheduler.timerId = setInterval( () => schedulerTick(scheduler), HEARTBEAT_POLL_INTERVAL ); schedulerTick(scheduler); } /** * 停止调度器:杀死全部心跳,保留验证码标签页 */ function stopBatchScheduler() { const scheduler = state.batchScheduler; if (!scheduler) return; scheduler.running = false; if (scheduler.timerId) { clearInterval(scheduler.timerId); scheduler.timerId = null; } for (let i = 0; i < scheduler.slots.length; i++) { const slot = scheduler.slots[i]; if (slot) { storeDelete(BATCH_TASK_PREFIX + slot.taskId); storeDelete(BATCH_DONE_PREFIX + slot.taskId); } } const doneCount = scheduler.completed + scheduler.failed; const remaining = scheduler.queue.length + scheduler.slots.filter(s => s !== null).length; showToast( `批量任务已停止:已完成 ${doneCount}/${scheduler.total},剩余 ${remaining} 条未执行。`, 'warning', 6000 ); addLog(`批量任务已停止:已完成 ${doneCount}/${scheduler.total},剩余 ${remaining} 条未执行。`, 'warning'); state.batchScheduler = null; } /** * 跳过指定槽位的任务:终止心跳并加入特殊信号,腾出槽位但不关闭标签页 */ function skipSlotTask(slotIndex) { const scheduler = state.batchScheduler; if (!scheduler) return; const slot = scheduler.slots[slotIndex]; if (!slot) return; const taskData = storeGet(BATCH_TASK_PREFIX + slot.taskId, null); if (taskData) { taskData._skip = true; taskData._skipKeepTab = true; storeSet(BATCH_TASK_PREFIX + slot.taskId, taskData); } scheduler.captchaTabs.push({ taskId: slot.taskId, rule: slot.rule, slotIndex: slotIndex }); addLog(`手动跳过:${slot.rule.name}(槽位${slotIndex + 1},标签页已保留)`, 'warning', {taskId: slot.taskId, slot: slotIndex}); } function getSchedulerSummary() { const s = state.batchScheduler; if (!s) return null; return { running: s.running, total: s.total, completed: s.completed, failed: s.failed, skipped: s.skipped, inProgress: s.slots.filter(t => t !== null).length, queueRemaining: s.queue.length, concurrency: s.slots.length, slots: s.slots, captchaTabs: s.captchaTabs }; } /****************************************************************** * 三、基础工具 ******************************************************************/ function createId() { if ( window.crypto && typeof window.crypto.randomUUID === 'function' ) { return window.crypto.randomUUID(); } return ( Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) ); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, Math.max(0, Number(ms) || 0)) ); } function clone(value) { return JSON.parse(JSON.stringify(value)); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function cssEscape(value) { if ( window.CSS && typeof window.CSS.escape === 'function' ) { return window.CSS.escape(String(value)); } return String(value).replace( /(^-?\d)|[^a-zA-Z0-9_-]/g, '\\$&' ); } function cssAttr(value) { return String(value) .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\A '); } function truncateText(value, length = 100) { const text = String(value || '') .replace(/\s+/g, ' ') .trim(); return text.length > length ? text.slice(0, length) + '…' : text; } function currentUrl() { return location.href.split('#')[0]; } function isErrorPage() { const url = location.href; const title = (document.title || '').toLowerCase(); if (/^chrome-error:/.test(url)) return true; if (/^about:neterror/.test(url)) return true; if (/^about:blank$/.test(url)) return true; const errorPatterns = [ '404', '403', '500', '502', '503', '504', 'not found', 'page not found', '无法访问', '无法连接', '找不到', '网页无法打开', 'err_connection', 'err_name', 'err_timed_out', 'dns_probe', 'server error', 'bad gateway', 'service unavailable', 'gateway timeout' ]; if (errorPatterns.some(p => title.includes(p))) return true; try { const bodyText = (document.body?.innerText || '').trim(); if (bodyText.length < 300 && bodyText.length > 0) { const lowerBody = bodyText.toLowerCase(); if (lowerBody.includes('error') || lowerBody.includes('404') || lowerBody.includes('not found') || lowerBody.includes('无法访问')) { return true; } } } catch (e) {} return false; } function defaultUrlPattern() { return location.origin + location.pathname + '*'; } function wildcardToRegExp(pattern) { const source = String(pattern || '*') .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*'); return new RegExp('^' + source + '$', 'i'); } function urlMatches(pattern, url) { try { return wildcardToRegExp(pattern).test(url); } catch (error) { return String(pattern) === String(url); } } function normalizePathname(pathname) { return String(pathname).replace(/\/+$/, '') || '/'; } function urlsEquivalent(expectedUrl, actualUrl) { if (!expectedUrl) { return true; } const settings = getSettings(); try { const expected = new URL(expectedUrl, location.href); const actual = new URL(actualUrl, location.href); const expPath = normalizePathname(expected.pathname); const actPath = normalizePathname(actual.pathname); if (settings.ignoreQueryForPageCheck) { return ( expected.origin === actual.origin && expPath === actPath ); } // 比较查询参数时,始终忽略 sc-recorder-task 参数(批量任务注入的内部参数) const filterInternalParams = (searchParams) => { const filtered = new URLSearchParams(searchParams); filtered.delete('sc-recorder-task'); return filtered.toString(); }; const expSearch = filterInternalParams(expected.search); const actSearch = filterInternalParams(actual.search); return ( expected.origin === actual.origin && expPath === actPath && expSearch === actSearch ); } catch (error) { const norm = (url) => String(url) .replace(/[?&]sc-recorder-task=[^&]+/, '') .replace(/\/+$/, '') .toLowerCase(); return norm(expectedUrl) === norm(actualUrl); } } function getRuleStartUrl(rule) { if (rule?.startUrl) { return rule.startUrl; } const pattern = String(rule?.urlPattern || ''); return pattern.replace(/\*/g, ''); } /** * 子标签页:启动心跳上报定时器 */ function startHeartbeat(batchTaskId) { stopHeartbeat(); state._heartbeatTimer = setInterval(() => { if (!batchTaskId) return; const taskData = storeGet(BATCH_TASK_PREFIX + batchTaskId, null); if (!taskData) { stopHeartbeat(); return; } if (taskData._skip) { stopHeartbeat(); state.playing = false; state.batchTaskId = null; restorePageDialogs(); const keepTab = taskData._skipKeepTab; storeDelete(BATCH_TASK_PREFIX + batchTaskId); storeSet(BATCH_DONE_PREFIX + batchTaskId, { success: false, isCaptcha: false, finishedAt: Date.now(), _skipped: true }); if (!keepTab) { window.close(); } return; } taskData._heartbeat = Date.now(); storeSet(BATCH_TASK_PREFIX + batchTaskId, taskData); }, 3000); } /** * 子标签页:停止心跳上报 */ function stopHeartbeat() { if (state._heartbeatTimer) { clearInterval(state._heartbeatTimer); state._heartbeatTimer = null; } } /** * 子标签页:每步更新心跳 */ function touchBatchHeartbeat(batchTaskId) { if (!batchTaskId) return; try { const task = storeGet(BATCH_TASK_PREFIX + batchTaskId, null); if (task && !task._skip) { task._heartbeat = Date.now(); storeSet(BATCH_TASK_PREFIX + batchTaskId, task); } } catch (e) {} } /** * 子标签页执行完毕后调用:写入完成记录 */ function writeBatchDone(taskId, success, isCaptcha) { storeSet(BATCH_DONE_PREFIX + taskId, { success: Boolean(success), isCaptcha: Boolean(isCaptcha), finishedAt: Date.now() }); } /** * 将批量任务ID附加到URL,确保页面跳转后仍能恢复任务状态 */ function addTaskParamToUrl(url, taskId) { if (!taskId) return url; try { const u = new URL(url, location.href); u.searchParams.set('sc-recorder-task', taskId); return u.toString(); } catch (e) { const sep = url.includes('?') ? '&' : '?'; return url + sep + 'sc-recorder-task=' + encodeURIComponent(taskId); } } /** * 安全地打开新标签页,优先使用 GM_openInTab 避免被拦截 */ function openNewTab(url, active) { if (typeof GM_openInTab === 'function') { GM_openInTab(url, { active: Boolean(active), insert: true }); return true; } try { const w = window.open(url, '_blank'); if (!w || w.closed) { console.warn('[网页录制器] window.open 被浏览器拦截,请允许弹出窗口。'); return false; } return true; } catch (e) { console.error('[网页录制器] 无法打开新标签页:', e); return false; } } function formatTime(timestamp) { if (!timestamp) { return '未知'; } try { return new Date(timestamp).toLocaleString(); } catch (error) { return '未知'; } } const MAX_LOG_ENTRIES = 500; /****************************************************************** * UI 工具:可拖拽 + 自动淡化 ******************************************************************/ function getUIPosition(key, defaultPos) { try { const raw = localStorage.getItem('SC_RECORDER_UI_POS_' + key); if (raw) { const pos = JSON.parse(raw); if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') { return pos; } } } catch (e) {} return defaultPos; } function saveUIPosition(key, x, y) { try { localStorage.setItem('SC_RECORDER_UI_POS_' + key, JSON.stringify({ x, y })); } catch (e) {} } /** * 让一个元素可拖拽 * @param {HTMLElement} element - 要拖拽的元素 * @param {string} posKey - 位置持久化key * @param {{x:number,y:number}} defaultPos - 默认位置 * @param {HTMLElement} dragHandle - 拖拽手柄(可选,默认为元素本身) */ function makeDraggable(element, posKey, defaultPos, dragHandle) { const handle = dragHandle || element; const pos = getUIPosition(posKey, defaultPos); element.style.left = pos.x + 'px'; element.style.top = pos.y + 'px'; element.style.right = 'auto'; element.style.bottom = 'auto'; let dragging = false; let startX = 0, startY = 0; let elemStartX = 0, elemStartY = 0; let moved = false; handle.style.cursor = 'move'; handle.addEventListener('mousedown', e => { if (e.button !== 0) return; // 排除点击按钮的情况(如果是按钮内部点击不触发拖拽) if (e.target.tagName === 'BUTTON' && handle !== element) return; dragging = true; moved = false; startX = e.clientX; startY = e.clientY; elemStartX = parseFloat(element.style.left) || 0; elemStartY = parseFloat(element.style.top) || 0; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { moved = true; } const maxX = window.innerWidth - element.offsetWidth; const maxY = window.innerHeight - element.offsetHeight; const newX = Math.max(0, Math.min(maxX, elemStartX + dx)); const newY = Math.max(0, Math.min(maxY, elemStartY + dy)); element.style.left = newX + 'px'; element.style.top = newY + 'px'; }); document.addEventListener('mouseup', () => { if (dragging && moved) { saveUIPosition(posKey, parseFloat(element.style.left), parseFloat(element.style.top)); } dragging = false; }); // 返回一个判断是否正在拖拽的函数,供 click 事件使用 return () => dragging && moved; } /** * 鼠标离开后自动淡化,鼠标进入后恢复 */ function setupAutoFade(element, delay = 2500) { let fadeTimer = null; function fadeOut() { element.style.transition = 'opacity 0.3s ease'; element.style.opacity = '0.25'; } function fadeIn() { if (fadeTimer) { clearTimeout(fadeTimer); fadeTimer = null; } element.style.transition = 'opacity 0.15s ease'; element.style.opacity = '1'; } element.addEventListener('mouseenter', fadeIn); element.addEventListener('mouseleave', () => { if (fadeTimer) clearTimeout(fadeTimer); fadeTimer = setTimeout(fadeOut, delay); }); // 初始延迟淡化 fadeTimer = setTimeout(fadeOut, delay); } function addLog(message, level, extra) { try { const logs = storeGet(STORAGE.EXEC_LOGS, []); logs.push({ time: Date.now(), level: level || 'info', message: String(message), extra: extra || null }); if (logs.length > MAX_LOG_ENTRIES) { logs.splice(0, logs.length - MAX_LOG_ENTRIES); } storeSet(STORAGE.EXEC_LOGS, logs); } catch (e) {} } function clearLogs() { storeSet(STORAGE.EXEC_LOGS, []); } function getLogs() { return storeGet(STORAGE.EXEC_LOGS, []); } function showToast(message, type = 'info', duration = 3500) { if (!document.documentElement) { return; } const colors = { info: '#2563eb', success: '#16a34a', warning: '#d97706', error: '#dc2626' }; const toast = document.createElement('div'); toast.dataset.scRecorderUi = 'true'; toast.textContent = String(message); Object.assign(toast.style, { position: 'fixed', top: '18px', left: '50%', transform: 'translateX(-50%)', zIndex: '2147483647', maxWidth: '88vw', padding: '11px 17px', borderRadius: '9px', color: '#fff', background: colors[type] || colors.info, boxShadow: '0 6px 24px rgba(0,0,0,.3)', fontSize: '14px', lineHeight: '1.5', fontFamily: 'Arial, "Microsoft YaHei", sans-serif', whiteSpace: 'pre-wrap', pointerEvents: 'none' }); document.documentElement.appendChild(toast); setTimeout(() => toast.remove(), duration); } function isRecorderUiEvent(event) { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; return path.some(node => { return ( node && node.nodeType === 1 && ( node.dataset?.scRecorderUi === 'true' || node.id === 'sc-web-recorder-manager-host' || node.id === 'sc-web-recorder-floating-host' ) ); }); } function getEventElement(event) { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; for (const node of path) { if (node && node.nodeType === 1) { return node; } } return event.target?.nodeType === 1 ? event.target : null; } /****************************************************************** * 四、变量系统 ******************************************************************/ function padNumber(value, length = 2) { return String(value).padStart(length, '0'); } function formatDate(date, format) { const values = { YYYY: date.getFullYear(), YY: String(date.getFullYear()).slice(-2), MM: padNumber(date.getMonth() + 1), M: date.getMonth() + 1, DD: padNumber(date.getDate()), D: date.getDate(), HH: padNumber(date.getHours()), H: date.getHours(), mm: padNumber(date.getMinutes()), m: date.getMinutes(), ss: padNumber(date.getSeconds()), s: date.getSeconds(), SSS: padNumber(date.getMilliseconds(), 3) }; return String(format).replace( /YYYY|SSS|YY|MM|DD|HH|mm|ss|M|D|H|m|s/g, token => values[token] ); } function randomString(length) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars[ Math.floor(Math.random() * chars.length) ]; } return result; } function randomDigits(length) { let result = ''; for (let i = 0; i < length; i++) { result += Math.floor(Math.random() * 10); } return result; } function createUuid() { if ( window.crypto && typeof window.crypto.randomUUID === 'function' ) { return window.crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' .replace(/[xy]/g, char => { const random = Math.random() * 16 | 0; const value = char === 'x' ? random : (random & 0x3 | 0x8); return value.toString(16); }); } function getNextCounter(variableName, start = 1, step = 1) { const counters = storeGet(STORAGE.COUNTERS, {}) || {}; const key = String(variableName || 'default'); let current = Number(counters[key]); if (!Number.isFinite(current)) { current = Number(start); } if (!Number.isFinite(current)) { current = 1; } let increment = Number(step); if (!Number.isFinite(increment)) { increment = 1; } counters[key] = current + increment; storeSet(STORAGE.COUNTERS, counters); return current; } function resetVariableCounters() { storeDelete(STORAGE.COUNTERS); showToast('所有递增变量计数器已重置。', 'success'); if (state.managerHost) { renderManager(); } } function evaluateVariableRule( ruleValue, variableName, counterCache = new Map() ) { let value = String(ruleValue ?? ''); const now = new Date(); value = value.replace( /\$i\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g, (match, start, step) => { const cacheKey = `${variableName}:${start}:${step}`; if (!counterCache.has(cacheKey)) { counterCache.set( cacheKey, getNextCounter( variableName, Number(start), Number(step) ) ); } return String(counterCache.get(cacheKey)); } ); value = value.replace(/\$i\+\+/g, () => { const cacheKey = `${variableName}:default`; if (!counterCache.has(cacheKey)) { counterCache.set( cacheKey, getNextCounter(variableName, 1, 1) ); } return String(counterCache.get(cacheKey)); }); value = value.replace( /\$datetime(?:\(([^)]*)\))?/g, (match, format) => formatDate( now, format || 'YYYY-MM-DD HH:mm:ss' ) ); value = value.replace( /\$date(?:\(([^)]*)\))?/g, (match, format) => formatDate(now, format || 'YYYY-MM-DD') ); value = value.replace( /\$time(?:\(([^)]*)\))?/g, (match, format) => formatDate(now, format || 'HH:mm:ss') ); value = value.replace( /\$timestamp10\b/g, String(Math.floor(now.getTime() / 1000)) ); value = value.replace( /\$timestamp\b/g, String(now.getTime()) ); value = value.replace( /\$uuid\b/g, () => createUuid() ); value = value.replace( /\$random\(\s*(\d+)\s*\)/g, (match, length) => randomString( Math.max( 1, Math.min(1000, Number(length)) ) ) ); value = value.replace( /\$digits\(\s*(\d+)\s*\)/g, (match, length) => randomDigits( Math.max( 1, Math.min(1000, Number(length)) ) ) ); value = value.replace( /\$number\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)/g, (match, minimum, maximum) => { let min = Number(minimum); let max = Number(maximum); if (min > max) { [min, max] = [max, min]; } return String( Math.floor( Math.random() * (max - min + 1) ) + min ); } ); value = value.replace( /\$pick\(([^)]*)\)/g, (match, optionsText) => { const options = String(optionsText) .split('|') .map(item => item.trim()) .filter(Boolean); if (!options.length) { return ''; } return options[ Math.floor(Math.random() * options.length) ]; } ); return value; } function replaceVariables(value, variables) { if (typeof value !== 'string') { return value; } const settings = getSettings(); let result = value; for (const [name, replacement] of Object.entries( variables || {} )) { const token = settings.variablePrefix + name + settings.variableSuffix; result = result.split(token).join( replacement === null || replacement === undefined ? '' : String(replacement) ); } return result; } function applyVariablesDeep(value, variables) { if (typeof value === 'string') { return replaceVariables(value, variables); } if (Array.isArray(value)) { return value.map(item => applyVariablesDeep(item, variables) ); } if (value && typeof value === 'object') { const output = {}; for (const [key, item] of Object.entries(value)) { output[key] = applyVariablesDeep(item, variables); } return output; } return value; } function resolveExecutionVariables(overrides = {}) { const settings = getSettings(); const result = {}; const counterCache = new Map(); for (const item of settings.fixedVariables) { if (!item || item.enabled === false) { continue; } const name = String(item.name || '').trim(); if (!name) { continue; } if ( Object.prototype.hasOwnProperty.call( overrides, name ) ) { continue; } result[name] = evaluateVariableRule( item.value, name, counterCache ); } Object.assign(result, overrides || {}); for (let i = 0; i < 5; i++) { let changed = false; for (const key of Object.keys(result)) { const oldValue = String(result[key] ?? ''); const newValue = replaceVariables(oldValue, result); if (newValue !== oldValue) { result[key] = newValue; changed = true; } } if (!changed) { break; } } return result; } /****************************************************************** * 五、DOM 定位器 ******************************************************************/ function isUniqueSelector(root, selector) { try { return root.querySelectorAll(selector).length === 1; } catch (error) { return false; } } function buildSelector(element, root) { if (!element || element.nodeType !== 1) { return ''; } if (element.id) { const selector = '#' + cssEscape(element.id); if (isUniqueSelector(root, selector)) { return selector; } } const tag = element.tagName.toLowerCase(); const attributes = [ 'data-testid', 'data-test', 'data-qa', 'data-cy', 'data-id', 'name', 'aria-label', 'title', 'role', 'placeholder', 'value' ]; for (const attribute of attributes) { const value = element.getAttribute(attribute); if (!value || value.length > 160) { continue; } const selector = `${tag}[${attribute}="${cssAttr(value)}"]`; if (isUniqueSelector(root, selector)) { return selector; } } const stableClasses = Array.from(element.classList || []) .filter(className => className.length <= 60 && !/\d{4,}/.test(className) && !/^[a-f0-9]{8,}$/i.test(className) ) .slice(0, 3); if (stableClasses.length) { const selector = tag + stableClasses .map(className => '.' + cssEscape(className) ) .join(''); if (isUniqueSelector(root, selector)) { return selector; } } const parts = []; let current = element; while ( current && current.nodeType === 1 && current !== root && parts.length < 10 ) { let part = current.tagName.toLowerCase(); if (current.id) { parts.unshift('#' + cssEscape(current.id)); break; } const parent = current.parentElement; if (parent) { const sameTag = Array.from(parent.children) .filter(child => child.tagName === current.tagName ); if (sameTag.length > 1) { part += `:nth-of-type(${ sameTag.indexOf(current) + 1 })`; } } parts.unshift(part); current = parent; } return parts.join(' > '); } function buildShadowPath(element) { const segments = []; let current = element; while (current && current.nodeType === 1) { const root = current.getRootNode(); segments.unshift( buildSelector(current, root) ); if (root instanceof ShadowRoot) { current = root.host; } else { break; } } return segments; } function buildFramePath(ownerDocument) { const path = []; let currentDocument = ownerDocument; while ( currentDocument?.defaultView && currentDocument.defaultView !== window ) { try { const frameElement = currentDocument.defaultView.frameElement; if (!frameElement) { break; } path.unshift( buildSelector( frameElement, frameElement.ownerDocument ) ); currentDocument = frameElement.ownerDocument; } catch (error) { break; } } return path; } function createLocator(element) { if (!element || element.nodeType !== 1) { return null; } return { framePath: buildFramePath(element.ownerDocument), shadowPath: buildShadowPath(element), css: buildSelector( element, element.getRootNode() ), tag: element.tagName.toLowerCase(), text: truncateText( element.innerText || element.textContent || element.value || '' ), attributes: { id: element.id || '', name: element.getAttribute('name') || '', type: element.getAttribute('type') || '', role: element.getAttribute('role') || '', ariaLabel: element.getAttribute('aria-label') || '' } }; } function resolveFrameDocument(framePath) { let currentDocument = document; for (const selector of framePath || []) { try { const frame = currentDocument.querySelector(selector); if (!frame?.contentDocument) { return null; } currentDocument = frame.contentDocument; } catch (error) { return null; } } return currentDocument; } function resolveShadowPath(rootDocument, path) { if (!path?.length) { return null; } let root = rootDocument; let element = null; for (let i = 0; i < path.length; i++) { try { element = root.querySelector(path[i]); } catch (error) { return null; } if (!element) { return null; } if (i < path.length - 1) { if (!element.shadowRoot) { return null; } root = element.shadowRoot; } } return element; } function resolveByText(rootDocument, locator) { if (!locator?.tag || !locator.text) { return null; } let elements; try { elements = Array.from( rootDocument.querySelectorAll(locator.tag) ); } catch (error) { return null; } const targetText = locator.text.trim(); return ( elements.find(element => truncateText( element.innerText || element.textContent || element.value || '' ) === targetText ) || elements.find(element => truncateText( element.innerText || element.textContent || element.value || '' ).includes(targetText) ) || null ); } function resolveLocator(locator) { if (!locator) { return null; } const rootDocument = resolveFrameDocument(locator.framePath); if (!rootDocument) { return null; } const shadowElement = resolveShadowPath( rootDocument, locator.shadowPath ); if (shadowElement) { return shadowElement; } if (locator.css) { try { const element = rootDocument.querySelector(locator.css); if (element) { return element; } } catch (error) { // 继续使用文本匹配 } } return resolveByText(rootDocument, locator); } async function waitForLocator(locator, timeout, signal) { const startTime = Date.now(); while (Date.now() - startTime <= timeout) { if (signal?.aborted) return null; const element = resolveLocator(locator); if (element?.isConnected) { return element; } await sleep(200); } return null; } /****************************************************************** * 六、录制功能 ******************************************************************/ function addRecordedStep(step) { if (!state.recording || !state.currentDraft) { return; } step.id = createId(); step.time = Date.now(); step.pageUrl = currentUrl(); const steps = state.currentDraft.steps; const last = steps[steps.length - 1]; const mode = getSettings().recordingMode; if ( mode === 'detailed' && step.type === 'input' && last?.type === 'input' && JSON.stringify(last.locator) === JSON.stringify(step.locator) && step.time - last.time < 3000 ) { last.value = step.value; last.time = step.time; last.pageUrl = step.pageUrl; } else if ( step.type === 'scroll' && last?.type === 'scroll' && JSON.stringify(last.locator) === JSON.stringify(step.locator) && step.time - last.time < 1500 ) { last.scrollTop = step.scrollTop; last.scrollLeft = step.scrollLeft; last.time = step.time; } else { steps.push(step); } state.currentDraft.updatedAt = Date.now(); storeSet(STORAGE.DRAFT, state.currentDraft); updateRecordingIndicator(); } function getPendingInputKey(locator) { return JSON.stringify(locator || {}); } function queueSimpleInputStep(step) { if (!step?.locator) { return; } const key = getPendingInputKey(step.locator); const existing = state.pendingInputs.get(key); if (existing) { existing.step = step; existing.updatedAt = Date.now(); } else { state.pendingInputSequence++; state.pendingInputs.set(key, { sequence: state.pendingInputSequence, updatedAt: Date.now(), step }); } updateRecordingIndicator(); } function flushPendingInputs() { if (!state.pendingInputs.size) { return; } const pending = Array.from(state.pendingInputs.values()) .sort((a, b) => a.sequence - b.sequence ); state.pendingInputs.clear(); for (const item of pending) { addRecordedStep(item.step); } } function recordClick(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } if (getSettings().recordingMode === 'simple') { flushPendingInputs(); } const element = getEventElement(event); if (!element) { return; } const rect = element.getBoundingClientRect(); addRecordedStep({ type: 'click', locator: createLocator(element), button: event.button, xRatio: rect.width ? Math.max( 0, Math.min( 1, (event.clientX - rect.left) / rect.width ) ) : 0.5, yRatio: rect.height ? Math.max( 0, Math.min( 1, (event.clientY - rect.top) / rect.height ) ) : 0.5 }); } function recordDoubleClick(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } if (getSettings().recordingMode === 'simple') { flushPendingInputs(); } const element = getEventElement(event); if (!element) { return; } addRecordedStep({ type: 'dblclick', locator: createLocator(element), xRatio: 0.5, yRatio: 0.5 }); } function buildInputStep(element) { const tag = element.tagName?.toLowerCase(); const type = String(element.type || '').toLowerCase(); const settings = getSettings(); if (type === 'file') { showToast( '浏览器限制:文件上传内容不能被录制。', 'warning' ); return null; } if ( type === 'password' && !settings.recordPasswords ) { return null; } if ( type === 'checkbox' || type === 'radio' ) { return { type: 'check', locator: createLocator(element), checked: Boolean(element.checked) }; } if (tag === 'select') { return { type: 'select', locator: createLocator(element), value: element.value, selectedIndex: element.selectedIndex }; } if (element.isContentEditable) { return { type: 'input', locator: createLocator(element), value: element.innerHTML, contentEditable: true }; } if ('value' in element) { return { type: 'input', locator: createLocator(element), value: element.value, inputType: type || tag }; } return null; } function recordInput(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } const element = getEventElement(event); if (!element) { return; } const step = buildInputStep(element); if (!step) { return; } if (getSettings().recordingMode === 'simple') { queueSimpleInputStep(step); } else { addRecordedStep(step); } } function recordSubmit(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } if (getSettings().recordingMode === 'simple') { flushPendingInputs(); } } function recordKeyDown(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } if ( event.altKey && String(event.key).toLowerCase() === 'r' ) { return; } const supportedKeys = new Set([ 'Enter', 'Tab', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Delete', 'Backspace' ]); if ( !supportedKeys.has(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey ) { return; } if (getSettings().recordingMode === 'simple') { if ( event.key === 'Backspace' || event.key === 'Delete' ) { return; } flushPendingInputs(); } const element = getEventElement(event); addRecordedStep({ type: 'key', locator: element ? createLocator(element) : null, key: event.key, code: event.code, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey }); } function recordScroll(event) { if ( !state.recording || isRecorderUiEvent(event) ) { return; } let target = event.target; const targetDocument = target?.ownerDocument || document; if ( target === targetDocument || target === targetDocument.documentElement || target === targetDocument.body ) { target = targetDocument.defaultView || window; } const isWindowTarget = target === window || target === targetDocument.defaultView; const key = isWindowTarget ? `window:${targetDocument.URL}` : JSON.stringify(createLocator(target)); clearTimeout(state.scrollTimers.get(key)); const timer = setTimeout(() => { state.scrollTimers.delete(key); if ( getSettings().recordingMode === 'simple' ) { flushPendingInputs(); } if (isWindowTarget) { addRecordedStep({ type: 'scroll', target: 'window', locator: null, scrollTop: target.scrollY || 0, scrollLeft: target.scrollX || 0 }); } else if (target?.nodeType === 1) { addRecordedStep({ type: 'scroll', target: 'element', locator: createLocator(target), scrollTop: target.scrollTop, scrollLeft: target.scrollLeft }); } }, 250); state.scrollTimers.set(key, timer); } function recordBeforeUnload() { if ( state.recording && getSettings().recordingMode === 'simple' ) { flushPendingInputs(); } } function bindRecorderToDocument(targetDocument) { if ( !targetDocument || targetDocument.__scRecorderBound ) { return; } try { targetDocument.__scRecorderBound = true; const bindings = [ ['click', recordClick, true], ['dblclick', recordDoubleClick, true], ['input', recordInput, true], ['change', recordInput, true], ['submit', recordSubmit, true], ['keydown', recordKeyDown, true], ['scroll', recordScroll, true] ]; for (const [name, handler, options] of bindings) { targetDocument.addEventListener( name, handler, options ); state.recorderCleanups.push(() => { targetDocument.removeEventListener( name, handler, options ); targetDocument.__scRecorderBound = false; }); } targetDocument .querySelectorAll('iframe,frame') .forEach(frame => { try { if (frame.contentDocument) { bindRecorderToDocument( frame.contentDocument ); } const loadHandler = () => { try { bindRecorderToDocument( frame.contentDocument ); } catch (error) { // 跨域 iframe 无法访问 } }; frame.addEventListener( 'load', loadHandler ); state.recorderCleanups.push(() => { frame.removeEventListener( 'load', loadHandler ); }); } catch (error) { // 跨域 iframe 无法访问 } }); } catch (error) { console.warn( '[网页录制器] 绑定文档失败:', error ); } } function scanFrames() { if (!state.recording) { return; } bindRecorderToDocument(document); document .querySelectorAll('iframe,frame') .forEach(frame => { try { bindRecorderToDocument( frame.contentDocument ); } catch (error) { // 忽略跨域 iframe } }); } function startRecording(resume = false) { if (state.recording) { showToast( '当前已经处于录制状态。', 'warning' ); return; } if (state.playing || getTaskState()) { showToast( '正在执行规则,不能同时录制。', 'warning' ); return; } state.pendingInputs.clear(); state.pendingInputSequence = 0; if (resume) { const draft = storeGet(STORAGE.DRAFT, null); if (!draft?.active) { return; } state.currentDraft = draft; } else { state.currentDraft = { id: createId(), active: true, name: '', group: DEFAULT_GROUP, urlPattern: defaultUrlPattern(), startUrl: currentUrl(), recordingMode: getSettings().recordingMode, createdAt: Date.now(), updatedAt: Date.now(), steps: [] }; storeSet( STORAGE.DRAFT, state.currentDraft ); } state.recording = true; bindRecorderToDocument(document); const observer = new MutationObserver(scanFrames); observer.observe(document.documentElement, { childList: true, subtree: true }); state.recorderCleanups.push( () => observer.disconnect() ); window.addEventListener( 'beforeunload', recordBeforeUnload, true ); state.recorderCleanups.push(() => { window.removeEventListener( 'beforeunload', recordBeforeUnload, true ); }); updateRecordingIndicator(); const modeName = getSettings().recordingMode === 'simple' ? '简洁录制' : '详细录制'; showToast( resume ? `已在新页面继续录制,当前为${modeName},按 Alt+R 停止。` : `开始${modeName},按 Alt+R 停止并保存。`, 'success', 4500 ); } function generateRuleName(template) { const title = document.title || '未命名'; const hostname = location.hostname || 'unknown'; const now = new Date(); const seq = (getRules().length + 1); const pad = n => String(n).padStart(2, '0'); return (template || '{title}') .replace(/\{title\}/g, title) .replace(/\{url\}/g, hostname) .replace(/\{seq\}/g, String(seq)) .replace(/\{date\}/g, `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}`) .replace(/\{time\}/g, `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`) .replace(/\{datetime\}/g, `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`) .replace(/\{timestamp\}/g, String(Date.now())); } function stopRecording() { if ( !state.recording || !state.currentDraft ) { showToast( '当前没有正在录制的规则。', 'warning' ); return; } if (getSettings().recordingMode === 'simple') { flushPendingInputs(); } state.recording = false; for ( const cleanup of state.recorderCleanups.splice(0) ) { try { cleanup(); } catch (error) { // 忽略清理错误 } } for (const timer of state.scrollTimers.values()) { clearTimeout(timer); } state.scrollTimers.clear(); state.pendingInputs.clear(); removeRecordingIndicator(); const draft = state.currentDraft; draft.active = false; draft.updatedAt = Date.now(); if (!draft.steps.length) { storeDelete(STORAGE.DRAFT); state.currentDraft = null; showToast( '未录制到任何操作,已取消保存。', 'warning' ); return; } const settings = getSettings(); let name = draft.name || ''; let pattern = draft.urlPattern || defaultUrlPattern(); let group = draft.group || ''; // 文本框变量自动替换 if (settings.autoReplaceInputs) { const varNames = (settings.inputVariableNames || []).filter(Boolean); let varIndex = 0; const prefix = settings.variablePrefix || '{'; const suffix = settings.variableSuffix || '}'; for (const step of draft.steps) { if (step.type === 'input' && step.value && varIndex < varNames.length) { step.value = prefix + varNames[varIndex] + suffix; varIndex++; } } } if (settings.silentSave) { // 静默保存:自动生成名称 const title = document.title || '未命名'; const hostname = location.hostname || 'unknown'; const now = new Date(); const seq = (getRules().length + 1); const pad = n => String(n).padStart(2, '0'); const template = settings.ruleNameTemplate || '{title}'; name = template .replace(/\{title\}/g, title) .replace(/\{url\}/g, hostname) .replace(/\{seq\}/g, String(seq)) .replace(/\{date\}/g, `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}`) .replace(/\{time\}/g, `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`) .replace(/\{datetime\}/g, `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`) .replace(/\{timestamp\}/g, String(Date.now())); } else { const defaultName = name || generateRuleName(settings.ruleNameTemplate); const promptName = window.prompt( '请输入规则名称:', defaultName ); if (promptName === null) { storeDelete(STORAGE.DRAFT); state.currentDraft = null; return; } name = promptName.trim() || defaultName; const promptPattern = window.prompt( '请输入 URL 匹配规则,可以使用 * 通配符:', pattern ); if (promptPattern === null) { storeDelete(STORAGE.DRAFT); state.currentDraft = null; return; } pattern = promptPattern.trim() || defaultUrlPattern(); const groups = getGroups(); const groupText = groups.length ? `已有分组:\n${groups.join('\n')}\n\n` : ''; const promptGroup = window.prompt( groupText + '请输入规则分组,留空表示未分组:', group ); if (promptGroup === null) { storeDelete(STORAGE.DRAFT); state.currentDraft = null; return; } group = promptGroup.trim(); } draft.name = name; draft.group = group; draft.urlPattern = pattern; const rules = getRules(); const index = rules.findIndex( rule => rule.id === draft.id ); if (index >= 0) { rules[index] = draft; } else { rules.push(draft); } saveRules(rules); if (draft.group) { saveGroups([ ...getGroups(), draft.group ]); } storeDelete(STORAGE.DRAFT); state.currentDraft = null; showToast( `规则“${draft.name}”已保存,共 ${draft.steps.length} 步。`, 'success', 5000 ); } function toggleRecording() { if (state.recording) { stopRecording(); } else { startRecording(false); } } function updateRecordingIndicator() { if ( !document.documentElement || !state.recording ) { return; } if (!state.recordingIndicator) { const indicator = document.createElement('div'); indicator.dataset.scRecorderUi = 'true'; Object.assign(indicator.style, { position: 'fixed', zIndex: '2147483647', padding: '8px 12px', borderRadius: '999px', background: '#dc2626', color: '#fff', fontSize: '13px', fontWeight: 'bold', fontFamily: 'Arial, "Microsoft YaHei", sans-serif', boxShadow: '0 4px 14px rgba(0,0,0,.25)', userSelect: 'none', cursor: 'move' }); const isDragging = makeDraggable( indicator, 'recordingIndicator', { x: window.innerWidth - 150, y: 14 } ); indicator.onclick = event => { if (isDragging()) return; event.preventDefault(); event.stopPropagation(); stopRecording(); }; setupAutoFade(indicator, 2500); document.documentElement.appendChild( indicator ); state.recordingIndicator = indicator; } const modeName = getSettings().recordingMode === 'simple' ? '简洁' : '详细'; const pendingCount = state.pendingInputs.size; state.recordingIndicator.textContent = `● ${modeName}录制:` + `${state.currentDraft?.steps?.length || 0} 步` + ( pendingCount ? `,待保存输入 ${pendingCount}` : '' ); } function removeRecordingIndicator() { state.recordingIndicator?.remove(); state.recordingIndicator = null; } /****************************************************************** * 七、网页事件模拟 ******************************************************************/ function setNativeProperty( element, property, value ) { let prototype = Object.getPrototypeOf(element); while (prototype) { const descriptor = Object.getOwnPropertyDescriptor( prototype, property ); if (descriptor?.set) { descriptor.set.call(element, value); return; } prototype = Object.getPrototypeOf(prototype); } element[property] = value; } function dispatchInputEvents(element, value) { try { const view = element.ownerDocument?.defaultView || window; const InputEventConstructor = view.InputEvent || InputEvent; element.dispatchEvent( new InputEventConstructor( 'beforeinput', { bubbles: true, composed: true, cancelable: true, inputType: 'insertText', data: String(value ?? '') } ) ); } catch (error) { // 某些浏览器不支持构造 InputEvent } element.dispatchEvent( new Event('input', { bubbles: true, composed: true }) ); element.dispatchEvent( new Event('change', { bubbles: true, composed: true }) ); element.dispatchEvent( new Event('blur', { bubbles: false, composed: true }) ); } function getMousePosition(element, step) { const rect = element.getBoundingClientRect(); return { clientX: rect.left + rect.width * ( typeof step.xRatio === 'number' ? step.xRatio : 0.5 ), clientY: rect.top + rect.height * ( typeof step.yRatio === 'number' ? step.yRatio : 0.5 ) }; } function dispatchMouse( element, name, position, detail = 1 ) { const view = element.ownerDocument?.defaultView || window; const options = { bubbles: true, composed: true, cancelable: true, view, clientX: position.clientX, clientY: position.clientY, screenX: position.clientX, screenY: position.clientY, button: 0, buttons: name.includes('down') ? 1 : 0, detail }; try { const PointerEventConstructor = view.PointerEvent; if ( name.startsWith('pointer') && typeof PointerEventConstructor === 'function' ) { element.dispatchEvent( new PointerEventConstructor( name, { ...options, pointerId: 1, pointerType: 'mouse', isPrimary: true } ) ); return; } } catch (error) { // 回退到 MouseEvent } const MouseEventConstructor = view.MouseEvent || MouseEvent; element.dispatchEvent( new MouseEventConstructor(name, options) ); } async function executeClick(element, step) { if (getSettings().scrollIntoView) { element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); await sleep(100); } try { element.focus({ preventScroll: true }); } catch (error) { try { element.focus(); } catch (ignore) { // 元素不可聚焦 } } const position = getMousePosition(element, step); dispatchMouse( element, 'pointerover', position ); dispatchMouse( element, 'mouseover', position ); dispatchMouse( element, 'pointerdown', position ); dispatchMouse( element, 'mousedown', position ); dispatchMouse( element, 'pointerup', position ); dispatchMouse( element, 'mouseup', position ); if (typeof element.click === 'function') { element.click(); } else { dispatchMouse( element, 'click', position ); } } async function executeInput(element, step) { if (getSettings().scrollIntoView) { element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); } try { element.focus(); } catch (error) { // 继续设置内容 } if ( step.contentEditable || element.isContentEditable ) { element.innerHTML = String(step.value ?? ''); dispatchInputEvents( element, step.value ); return; } setNativeProperty( element, 'value', String(step.value ?? '') ); dispatchInputEvents( element, step.value ); } async function executeCheck(element, step) { const desired = Boolean(step.checked); if (Boolean(element.checked) !== desired) { try { setNativeProperty( element, 'checked', desired ); } catch (error) { element.checked = desired; } } element.dispatchEvent( new Event('input', { bubbles: true, composed: true }) ); element.dispatchEvent( new Event('change', { bubbles: true, composed: true }) ); } async function executeSelect(element, step) { const options = Array.from(element.options || []); const value = String(step.value ?? ''); const valueExists = options.some(option => option.value === value ); if (valueExists) { setNativeProperty( element, 'value', value ); } else if ( options[step.selectedIndex] ) { element.selectedIndex = step.selectedIndex; } element.dispatchEvent( new Event('input', { bubbles: true, composed: true }) ); element.dispatchEvent( new Event('change', { bubbles: true, composed: true }) ); } async function executeKey(element, step) { const target = element || document.activeElement || document.body; try { target.focus(); } catch (error) { // 元素不可聚焦 } const view = target.ownerDocument?.defaultView || window; const KeyboardEventConstructor = view.KeyboardEvent || KeyboardEvent; const options = { key: step.key, code: step.code || '', ctrlKey: Boolean(step.ctrlKey), shiftKey: Boolean(step.shiftKey), altKey: Boolean(step.altKey), metaKey: Boolean(step.metaKey), bubbles: true, composed: true, cancelable: true }; target.dispatchEvent( new KeyboardEventConstructor( 'keydown', options ) ); target.dispatchEvent( new KeyboardEventConstructor( 'keypress', options ) ); target.dispatchEvent( new KeyboardEventConstructor( 'keyup', options ) ); if ( step.key === 'Enter' && target.form && typeof target.form.requestSubmit === 'function' ) { await sleep(100); if (target.isConnected) { try { target.form.requestSubmit(); } catch (error) { // 表单可能不允许提交 } } } } async function executeStepWithTimeout(step, signal) { const settings = getSettings(); const timeout = Math.max(5000, Number(settings.stepTimeout) || 30000); return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; signal?.abort(); reject(new Error(`步骤执行超时 ${timeout / 1000} 秒`)); } }, timeout); executeStepInternal(step, signal).then( result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } }, err => { if (!settled) { settled = true; clearTimeout(timer); reject(err); } } ); }); } async function executeStepInternal(step, signal) { const settings = getSettings(); if (step.type === 'wait') { const duration = Number(step.duration) || settings.stepDelay; const steps = Math.ceil(duration / 200); for (let i = 0; i < steps; i++) { if (signal?.aborted) return; await sleep(Math.min(200, duration - i * 200)); } return; } let element = null; if (step.locator) { element = await waitForLocator( step.locator, Number(settings.elementTimeout), signal ); if (!element) { throw new Error( `未找到元素:${ step.locator.css || step.locator.text || '未知元素' }` ); } } switch (step.type) { case 'click': await executeClick(element, step); break; case 'dblclick': await executeClick(element, step); await sleep(80); await executeClick(element, step); dispatchMouse( element, 'dblclick', getMousePosition(element, step), 2 ); break; case 'input': await executeInput(element, step); break; case 'check': await executeCheck(element, step); break; case 'select': await executeSelect(element, step); break; case 'key': await executeKey(element, step); break; case 'scroll': if ( step.target === 'window' || !step.locator ) { window.scrollTo({ top: Number(step.scrollTop) || 0, left: Number(step.scrollLeft) || 0, behavior: 'auto' }); } else { element.scrollTo({ top: Number(step.scrollTop) || 0, left: Number(step.scrollLeft) || 0, behavior: 'auto' }); } break; default: console.warn( '[网页录制器] 未知步骤类型:', step.type ); } } let _stepSignal = null; async function executeStep(step) { _stepSignal = createCancelSignal(); return executeStepWithTimeout(step, _stepSignal); } /****************************************************************** * 八、统一任务系统 * * mode: * single 单条规则 * ruleBatch 多条规则依次执行 * dataBatch 单条规则按多行 JSON 数据执行 ******************************************************************/ function createSingleTask( ruleId, overrideVariables = {} ) { return { active: true, mode: 'single', ruleIds: [ruleId], ruleIndex: 0, stepIndex: 0, variableOverrides: overrideVariables || {}, currentVariables: null, createdAt: Date.now() }; } function createDataBatchTask(ruleId, rows) { return { active: true, mode: 'dataBatch', ruleIds: [ruleId], ruleIndex: 0, rows, rowIndex: 0, stepIndex: 0, currentVariables: null, createdAt: Date.now() }; } function getCurrentTaskRule(task) { const rules = getRules(); if (task.mode === 'dataBatch') { return rules.find( rule => rule.id === task.ruleIds?.[0] ); } const ruleId = task.ruleIds?.[task.ruleIndex || 0]; return rules.find(rule => rule.id === ruleId); } function getCurrentTaskOverrides(task) { if (task.mode === 'dataBatch') { const row = task.rows?.[task.rowIndex || 0]; return row && typeof row === 'object' ? row : {}; } return task.variableOverrides || {}; } function getCurrentTaskStartUrl(task, rule) { if (task.mode === 'dataBatch') { const row = task.rows?.[task.rowIndex || 0] || {}; return row.__url || getRuleStartUrl(rule); } return getRuleStartUrl(rule); } function getTaskProgressText(task, rule) { if (state.batchTaskId) { return `批量任务:${rule.name}`; } if (task.mode === 'dataBatch') { return ( `批量数据 ${ Number(task.rowIndex || 0) + 1 }/${task.rows.length}:${rule.name}` ); } return `执行规则:${rule.name}`; } function startTask(task) { if (state.recording) { showToast( '请先停止录制。', 'warning' ); return; } if (state.playing || getTaskState()) { showToast( '已有任务正在执行,请先停止当前任务。', 'warning' ); return; } saveTaskState(task); continueTask(); } function executeRuleById( ruleId, overrideVariables = {} ) { const rule = getRules().find( item => item.id === ruleId ); if (!rule) { showToast( '找不到指定规则。', 'error' ); return; } startTask( createSingleTask( ruleId, overrideVariables ) ); } function executeSelectedRules( ruleIds, overrideVariables = {} ) { const validIds = []; const rules = getRules(); const ruleMap = new Map( rules.map(rule => [rule.id, rule]) ); for (const id of ruleIds || []) { if (ruleMap.has(id)) { validIds.push(id); } } if (!validIds.length) { showToast( '请至少选择一条有效规则。', 'warning' ); return; } // 创建主控调度器 const scheduler = createBatchScheduler(validIds, overrideVariables); state.batchScheduler = scheduler; startBatchScheduler(scheduler); showToast( `批量任务已启动,共 ${validIds.length} 条规则,并发 ${scheduler.slots.length}。`, 'success', 4000 ); if (state.managerHost) { renderManager(); } } function startDataBatch(ruleId, rows) { if ( !Array.isArray(rows) || !rows.length ) { showToast( '批量数据必须是非空 JSON 数组。', 'error' ); return; } const rule = getRules().find( item => item.id === ruleId ); if (!rule) { showToast( '批量任务对应的规则不存在。', 'error' ); return; } startTask( createDataBatchTask(ruleId, rows) ); } async function waitForExpectedPage( expectedUrl, timeout ) { const start = Date.now(); while (Date.now() - start <= timeout) { if ( urlsEquivalent( expectedUrl, currentUrl() ) ) { return true; } await sleep(250); } return false; } async function runCurrentRule( task, rule, variables ) { let index = Number(task.stepIndex) || 0; const RULE_GLOBAL_TIMEOUT = 180000; // 3分钟全局超时 const deadline = Date.now() + RULE_GLOBAL_TIMEOUT; showToast( `${getTaskProgressText(task, rule)}\n` + `从第 ${index + 1} 步开始,共 ${rule.steps.length} 步。`, 'info', 4500 ); addLog( `开始执行:${rule.name}(第 ${index + 1}/${rule.steps.length} 步)`, 'info', {ruleId: rule.id} ); while (index < rule.steps.length) { // 全局超时检查 if (Date.now() > deadline) { throw new Error(`规则全局超时(超过 ${RULE_GLOBAL_TIMEOUT / 1000} 秒,共 ${rule.steps.length} 步中执行到第 ${index + 1} 步)`); } const rawStep = clone(rule.steps[index]); const step = applyVariablesDeep( rawStep, variables ); if ( step.pageUrl && !urlsEquivalent( step.pageUrl, currentUrl() ) ) { const arrived = await waitForExpectedPage( step.pageUrl, getSettings().elementTimeout ); if (!arrived) { showToast( `第 ${index + 1} 步页面无法加载,跳过该步骤。`, 'warning', 4000 ); addLog(`步骤 ${index + 1} 跳过:页面未加载到 ${step.pageUrl}`, 'warning', {ruleId: rule.id, stepIndex: index + 1}); index++; continue; } } task.stepIndex = index + 1; task.currentVariables = variables; saveTaskState(task); // 每步更新心跳 touchBatchHeartbeat(state.batchTaskId); try { await executeStep(step); } catch (error) { task.stepIndex = index; task.currentVariables = variables; saveTaskState(task); throw error; } index++; addLog( `步骤 ${index}/${rule.steps.length} 完成:${step.type}${ step.locator ? ` → ${step.locator.css || step.locator.text || ''}` : '' }`, 'debug', {ruleId: rule.id, stepIndex: index} ); await sleep( Number(getSettings().stepDelay) || 0 ); } } async function completeCurrentTaskUnit(task, rule) { if (task.mode === 'single') { if (state.batchTaskId) { const originalRule = getCurrentTaskRule(task); const isCaptcha = originalRule?.group && originalRule.group.includes('验证码'); stopHeartbeat(); restorePageDialogs(); writeBatchDone(state.batchTaskId, true, isCaptcha); storeDelete(BATCH_TASK_PREFIX + state.batchTaskId); state.batchTaskId = null; clearTaskState(); state.playing = false; if (isCaptcha) { showToast(`规则"${originalRule.name}"执行完成,标签已保留。`, 'success', 5000); addLog(`验证码规则完成:${originalRule.name}(标签已保留)`, 'success'); } else { addLog(`规则完成:${originalRule.name}(标签将关闭)`, 'success'); await sleep(300); window.close(); } return; } clearTaskState(); state.playing = false; showToast( `规则"${rule.name}"执行完成。`, 'success', 5000 ); return; } if (task.mode === 'dataBatch') { task.rowIndex = Number(task.rowIndex || 0) + 1; task.stepIndex = 0; task.currentVariables = null; if (task.rowIndex >= task.rows.length) { clearTaskState(); state.playing = false; showToast( `数据批量执行完成,共处理 ${task.rows.length} 条数据。`, 'success', 6000 ); return; } saveTaskState(task); state.playing = false; await sleep(800); continueTask(); } } function togglePause() { state.paused = !state.paused; if (state.paused) { showToast('任务已暂停,点击继续按钮恢复执行。', 'warning', 4000); } else { showToast('任务已恢复。', 'success', 2000); continueTask(); } updateFloatingButton(); if (state.managerHost) { renderManager(); } } async function continueTask() { if (state.playing) { return; } const task = getTaskState(); if (!task) { return; } if (state.paused) { return; } if (state.recording) { showToast( '录制状态下不能执行任务。', 'warning' ); return; } const rule = getCurrentTaskRule(task); if (!rule) { clearTaskState(); showToast( '任务中的规则不存在,任务已停止。', 'error' ); return; } if (!Array.isArray(rule.steps)) { clearTaskState(); showToast( `规则“${rule.name}”的数据无效。`, 'error' ); return; } let variables = task.currentVariables; if (!variables) { variables = resolveExecutionVariables( getCurrentTaskOverrides(task) ); task.currentVariables = variables; saveTaskState(task); } const startUrl = getCurrentTaskStartUrl(task, rule); if ( Number(task.stepIndex || 0) === 0 && startUrl && !urlsEquivalent( startUrl, currentUrl() ) ) { const url = state.batchTaskId ? addTaskParamToUrl(startUrl, state.batchTaskId) : startUrl; // 防重定向循环保护:5秒内重复触发则终止 const now = Date.now(); if (state.batchTaskId && now - state._lastBatchRedirect < 5000) { addLog(`规则跳转循环保护触发:${rule.name}(${startUrl})`, 'error'); stopHeartbeat(); writeBatchDone(state.batchTaskId, false, false); storeDelete(BATCH_TASK_PREFIX + state.batchTaskId); state.batchTaskId = null; state.playing = false; restorePageDialogs(); await sleep(500); window.close(); return; } state._lastBatchRedirect = now; location.href = url; return; } state.playing = true; blockPageDialogs(); try { await runCurrentRule( task, rule, variables ); await completeCurrentTaskUnit( task, rule ); } catch (error) { restorePageDialogs(); state.playing = false; console.error( '[网页录制器] 执行失败:', error ); const failedStep = rule.steps?.[index]; addLog( `规则失败:${rule.name}(${error.message || error})`, 'error', { ruleId: rule.id, ruleGroup: rule.group, ruleUrl: rule.urlPattern, stepIndex: index + 1, stepTotal: rule.steps.length, stepType: failedStep?.type, stepLocator: failedStep?.locator, pageUrl: currentUrl(), error: error.message || String(error) } ); showToast( `规则"${rule.name}"执行失败:${ error.message || error }\n延时 5 秒后跳过该规则,继续执行后续任务。`, 'error', 6000 ); // 延时 5 秒后跳过当前规则,继续后续任务 await sleep(5000); if (task.mode === 'single') { if (state.batchTaskId) { stopHeartbeat(); writeBatchDone(state.batchTaskId, false, false); storeDelete(BATCH_TASK_PREFIX + state.batchTaskId); state.batchTaskId = null; state.playing = false; restorePageDialogs(); await sleep(3000); window.close(); return; } clearTaskState(); return; } if (task.mode === 'dataBatch') { task.rowIndex = Number(task.rowIndex || 0) + 1; task.stepIndex = 0; task.currentVariables = null; if (task.rowIndex >= task.rows.length) { clearTaskState(); state.playing = false; restorePageDialogs(); showToast( `数据批量执行完成,共处理 ${task.rows.length} 条数据。`, 'success', 6000 ); return; } saveTaskState(task); await sleep(800); continueTask(); } } restorePageDialogs(); } function stopPlayback() { clearTaskState(); stopBatchScheduler(); storeDelete(STORAGE.OLD_RUN_STATE); storeDelete(STORAGE.OLD_BATCH_STATE); state.playing = false; state.paused = false; restorePageDialogs(); showToast( '已停止当前规则和批量任务。', 'warning' ); if (state.managerHost) { renderManager(); } } function executeMatchingRule() { const rules = getRules().filter(rule => urlMatches( rule.urlPattern, currentUrl() ) ); if (!rules.length) { showToast( '当前 URL 没有匹配的规则。', 'warning' ); return; } if (rules.length === 1) { executeRuleById(rules[0].id); return; } state.manager.search = ''; state.manager.group = '__matched__'; state.manager.tab = 'rules'; openManager(); } /****************************************************************** * 九、规则筛选、分组和批量操作 ******************************************************************/ function getRuleSearchText(rule) { const stepText = (rule.steps || []) .map(step => { return [ step.type, step.value, step.pageUrl, step.locator?.css, step.locator?.text ] .filter(Boolean) .join(' '); }) .join(' '); return [ rule.name, rule.group, rule.urlPattern, rule.startUrl, stepText ] .filter(Boolean) .join(' ') .toLowerCase(); } function getFilteredRules() { const rules = getRules(); const search = String(state.manager.search || '') .trim() .toLowerCase(); return rules.filter(rule => { let groupMatched = true; if (state.manager.group === '__ungrouped__') { groupMatched = !rule.group; } else if ( state.manager.group === '__matched__' ) { groupMatched = urlMatches( rule.urlPattern, currentUrl() ); } else if ( state.manager.group !== '__all__' ) { groupMatched = rule.group === state.manager.group; } if (!groupMatched) { return false; } if (!search) { return true; } return getRuleSearchText(rule) .includes(search); }); } function cleanupSelectedRules() { const validIds = new Set( getRules().map(rule => rule.id) ); for ( const id of Array.from( state.manager.selectedRuleIds ) ) { if (!validIds.has(id)) { state.manager.selectedRuleIds.delete(id); } } } function createGroup() { const name = window.prompt( '请输入新分组名称:', '' ); if (!name?.trim()) { return; } const normalized = name.trim(); const groups = getGroups(); if (groups.includes(normalized)) { showToast( '该分组已经存在。', 'warning' ); return; } saveGroups([...groups, normalized]); state.manager.group = normalized; renderManager(); showToast( `分组“${normalized}”已创建。`, 'success' ); } function renameCurrentGroup() { const oldName = state.manager.group; if ( !oldName || oldName.startsWith('__') ) { showToast( '请先在左侧选择一个具体分组。', 'warning' ); return; } const newName = window.prompt( '请输入新的分组名称:', oldName ); if (!newName?.trim()) { return; } const normalized = newName.trim(); if ( normalized !== oldName && getGroups().includes(normalized) ) { showToast( '目标分组名称已经存在。', 'error' ); return; } const rules = getRules(); for (const rule of rules) { if (rule.group === oldName) { rule.group = normalized; rule.updatedAt = Date.now(); } } saveRules(rules); saveGroups( getGroups() .filter(name => name !== oldName) .concat(normalized) ); state.manager.group = normalized; renderManager(); showToast( `分组已重命名为“${normalized}”。`, 'success' ); } function deleteCurrentGroup() { const group = state.manager.group; if ( !group || group.startsWith('__') ) { showToast( '请先在左侧选择一个具体分组。', 'warning' ); return; } if ( !window.confirm( `确定删除分组“${group}”吗?\n分组中的规则不会删除,将移动到“未分组”。` ) ) { return; } const rules = getRules(); for (const rule of rules) { if (rule.group === group) { rule.group = DEFAULT_GROUP; rule.updatedAt = Date.now(); } } saveRules(rules); saveGroups( getGroups().filter(name => name !== group) ); state.manager.group = '__all__'; renderManager(); showToast( `分组“${group}”已删除,原规则已移动到未分组。`, 'success' ); } function moveSelectedRules(group) { const selected = state.manager.selectedRuleIds; if (!selected.size) { showToast( '请先选择需要移动的规则。', 'warning' ); return; } const rules = getRules(); let changed = 0; for (const rule of rules) { if (selected.has(rule.id)) { rule.group = group || DEFAULT_GROUP; rule.updatedAt = Date.now(); changed++; } } saveRules(rules); if (group) { saveGroups([ ...getGroups(), group ]); } renderManager(); showToast( `已移动 ${changed} 条规则到“${ group || '未分组' }”。`, 'success' ); } function deleteSelectedRules() { const selected = state.manager.selectedRuleIds; if (!selected.size) { showToast( '请先选择需要删除的规则。', 'warning' ); return; } if ( !window.confirm( `确定删除选中的 ${selected.size} 条规则吗?此操作不可撤销。` ) ) { return; } const rules = getRules(); const nextRules = rules.filter( rule => !selected.has(rule.id) ); saveRules(nextRules); selected.clear(); renderManager(); showToast( '选中的规则已删除。', 'success' ); } function copySelectedRules() { const selected = state.manager.selectedRuleIds; if (!selected.size) { showToast( '请先选择需要复制的规则。', 'warning' ); return; } const rules = getRules(); const copiedRules = []; for (const rule of rules) { if (!selected.has(rule.id)) { continue; } const copied = clone(rule); copied.id = createId(); copied.name += ' - 副本'; copied.createdAt = Date.now(); copied.updatedAt = Date.now(); copiedRules.push(copied); } rules.push(...copiedRules); saveRules(rules); state.manager.selectedRuleIds = new Set(copiedRules.map(rule => rule.id)); renderManager(); showToast( `已复制 ${copiedRules.length} 条规则。`, 'success' ); } /****************************************************************** * 十、管理界面 ******************************************************************/ const MANAGER_CSS = ` :host { all: initial; font-family: Arial, "Microsoft YaHei", sans-serif; color: #1f2937; } * { box-sizing: border-box; } button, input, select, textarea { font-family: inherit; } .overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 18px; background: rgba(15, 23, 42, .68); } .panel { width: min(1280px, 98vw); height: min(900px, 96vh); overflow: hidden; display: flex; flex-direction: column; background: #fff; border-radius: 15px; box-shadow: 0 24px 80px rgba(0, 0, 0, .38); } .header { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; color: #fff; background: linear-gradient(135deg, #1d4ed8, #2563eb 55%, #3b82f6); } .header-title { min-width: 0; } .header-title h2 { margin: 0; font-size: 19px; line-height: 1.4; } .header-title p { margin: 3px 0 0; color: rgba(255,255,255,.8); font-size: 12px; } .header-actions, .toolbar, .actions, .tab-bar, .group-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } button { appearance: none; border: 1px solid #d1d5db; border-radius: 7px; padding: 7px 11px; background: #fff; color: #1f2937; cursor: pointer; font-size: 13px; line-height: 1.2; transition: background .15s, border-color .15s, opacity .15s, transform .15s; } button:hover { background: #f3f4f6; border-color: #9ca3af; } button:active { transform: translateY(1px); } button:disabled { opacity: .48; cursor: not-allowed; } button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.primary:hover { background: #1d4ed8; } button.success { color: #fff; background: #16a34a; border-color: #16a34a; } button.success:hover { background: #15803d; } button.warning { color: #fff; background: #d97706; border-color: #d97706; } button.warning:hover { background: #b45309; } button.danger { color: #fff; background: #dc2626; border-color: #dc2626; } button.danger:hover { background: #b91c1c; } button.header-button { color: #fff; background: rgba(255,255,255,.14); border-color: rgba(255,255,255,.35); } button.header-button:hover { background: rgba(255,255,255,.24); } .tab-bar { flex: none; padding: 0 18px; border-bottom: 1px solid #e5e7eb; background: #f8fafc; } .tab-button { padding: 12px 4px 10px; margin-right: 18px; border: 0; border-radius: 0; color: #64748b; background: transparent; border-bottom: 3px solid transparent; font-weight: 600; } .tab-button:hover { color: #1d4ed8; background: transparent; } .tab-button.active { color: #1d4ed8; border-bottom-color: #2563eb; } .workspace { min-height: 0; flex: 1; display: flex; } .sidebar { flex: none; width: 220px; overflow: auto; padding: 14px 10px; border-right: 1px solid #e5e7eb; background: #f8fafc; } .sidebar-title { display: flex; align-items: center; justify-content: space-between; padding: 4px 7px 10px; color: #475569; font-size: 12px; font-weight: 700; text-transform: uppercase; } .group-list { display: flex; flex-direction: column; gap: 3px; } .group-item { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 0; border-radius: 8px; color: #334155; background: transparent; text-align: left; } .group-item:hover { color: #1d4ed8; background: #eaf2ff; } .group-item.active { color: #1d4ed8; background: #dbeafe; font-weight: 700; } .group-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .group-count { flex: none; min-width: 24px; padding: 2px 6px; border-radius: 999px; color: #64748b; background: rgba(148,163,184,.16); font-size: 11px; text-align: center; } .sidebar-footer { margin-top: 14px; padding-top: 12px; border-top: 1px solid #e2e8f0; } .main { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; background: #fff; } .main-toolbar { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 16px; border-bottom: 1px solid #e5e7eb; } .search-wrap { position: relative; flex: 1; max-width: 470px; } .search-icon { position: absolute; top: 50%; left: 11px; transform: translateY(-50%); color: #94a3b8; pointer-events: none; } .search-input { width: 100%; padding-left: 34px !important; } input[type="text"], input[type="number"], select, textarea { width: 100%; padding: 8px 9px; border: 1px solid #d1d5db; border-radius: 7px; outline: none; color: #111827; background: #fff; font-size: 13px; } input:focus, select:focus, textarea:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); } .selection-toolbar { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #bfdbfe; background: #eff6ff; } .selection-summary { color: #1e40af; font-size: 13px; font-weight: 700; } .rules-content { min-height: 0; flex: 1; overflow: auto; padding: 0; } table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; } th, td { padding: 10px 9px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: middle; } th { position: sticky; top: 0; z-index: 2; color: #475569; background: #f8fafc; font-size: 12px; font-weight: 700; } tbody tr:hover { background: #f8fbff; } tbody tr.selected { background: #eff6ff; } .checkbox-column { width: 44px; text-align: center; } .steps-column { width: 78px; } .group-column { width: 150px; } .time-column { width: 150px; } .operation-column { width: 260px; } .mode-column { width: 90px; } .variables-column { width: 160px; } .variables-column .badge.var { display: inline-flex; margin: 1px 2px; color: #7c3aed; background: #f3e8ff; font-size: 11px; } .card-variables { margin: 8px 0 12px; padding: 8px 10px; border-radius: 6px; background: #faf5ff; font-size: 12px; line-height: 1.6; } .card-variables .badge.var { display: inline-flex; margin: 1px 2px; color: #7c3aed; background: #f3e8ff; font-size: 11px; } .input-var-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .input-var-row input { flex: 1; } .input-var-row button { flex: none; padding: 5px 9px; font-size: 12px; } .rule-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 5px; } .rule-name { color: #0f172a; font-size: 14px; font-weight: 700; } .badge { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 999px; color: #475569; background: #f1f5f9; font-size: 11px; line-height: 1.5; } .badge.match { color: #166534; background: #dcfce7; } .badge.group { color: #1e40af; background: #dbeafe; } .muted { color: #64748b; font-size: 12px; line-height: 1.55; word-break: break-all; } .rule-url { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .empty { margin: 24px; padding: 40px 20px; border: 1px dashed #cbd5e1; border-radius: 12px; color: #64748b; background: #f8fafc; text-align: center; } .empty-title { margin-bottom: 7px; color: #334155; font-size: 16px; font-weight: 700; } .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 13px; padding: 15px; } .rule-card { position: relative; padding: 14px; border: 1px solid #e2e8f0; border-radius: 11px; background: #fff; box-shadow: 0 2px 8px rgba(15,23,42,.04); } .rule-card:hover { border-color: #93c5fd; box-shadow: 0 6px 20px rgba(37,99,235,.10); } .rule-card.selected { border-color: #3b82f6; background: #eff6ff; } .card-head { display: flex; align-items: flex-start; gap: 9px; } .card-main { min-width: 0; flex: 1; } .card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 12px 0; padding: 10px; border-radius: 8px; background: rgba(248,250,252,.9); } .meta-label { display: block; margin-bottom: 3px; color: #94a3b8; font-size: 11px; } .meta-value { color: #334155; font-size: 12px; font-weight: 600; } .content-page { min-height: 0; flex: 1; overflow: auto; padding: 18px 20px 30px; } .section { margin-bottom: 22px; padding: 17px; border: 1px solid #e2e8f0; border-radius: 11px; background: #fff; } .section-title { margin: 0 0 6px; color: #0f172a; font-size: 17px; } .section-description { margin: 0 0 15px; color: #64748b; font-size: 13px; line-height: 1.6; } .settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; } .field label { display: block; margin-bottom: 6px; color: #374151; font-size: 13px; font-weight: 600; } .checkbox-line { display: flex; align-items: center; gap: 8px; min-height: 36px; font-size: 13px; } .variable-table { width: 100%; table-layout: auto; } .variable-table th { position: static; } .help { margin-top: 14px; padding: 13px; border: 1px solid #dbeafe; border-radius: 9px; color: #334155; background: #eff6ff; font-family: Consolas, Monaco, monospace; font-size: 12px; white-space: pre-wrap; line-height: 1.7; } .notice { padding: 11px 13px; margin-bottom: 15px; border: 1px solid #fed7aa; border-radius: 8px; color: #9a3412; background: #fff7ed; font-size: 13px; line-height: 1.6; } .log-filter.active { font-weight: 700; border-bottom: 2px solid #2563eb; } .log-filter:hover { background: #eff6ff; } .task-status { padding: 11px 13px; margin-bottom: 15px; border: 1px solid #bfdbfe; border-radius: 8px; color: #1e40af; background: #eff6ff; font-size: 13px; line-height: 1.6; } .editor-mask { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; padding: 28px; background: rgba(15,23,42,.72); } .editor-panel { width: min(920px, 95vw); max-height: 91vh; display: flex; flex-direction: column; padding: 18px; border-radius: 12px; background: #fff; box-shadow: 0 20px 60px rgba(0,0,0,.38); } .editor-title { margin: 0 0 12px; color: #111827; font-size: 18px; } .editor-textarea { min-height: 420px; resize: vertical; font-family: Consolas, Monaco, monospace; line-height: 1.55; } .editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } @media (max-width: 900px) { .overlay { padding: 7px; } .panel { width: 100%; height: 98vh; } .header { align-items: flex-start; flex-direction: column; } .sidebar { width: 175px; } .operation-column { width: 230px; } .time-column { display: none; } } @media (max-width: 680px) { .workspace { flex-direction: column; } .sidebar { width: 100%; max-height: 145px; border-right: 0; border-bottom: 1px solid #e5e7eb; } .group-list { flex-direction: row; overflow-x: auto; } .group-item { width: auto; flex: none; } .main-toolbar, .selection-toolbar { align-items: stretch; flex-direction: column; } .search-wrap { max-width: none; } table, thead, tbody, tr, th, td { display: block; width: 100% !important; } thead { display: none; } tbody tr { position: relative; padding: 12px 42px 12px 12px; border-bottom: 1px solid #e2e8f0; } td { padding: 5px 0; border: 0; } td.checkbox-column { position: absolute; top: 13px; right: 10px; width: 28px !important; } .card-grid { grid-template-columns: 1fr; } .editor-mask { padding: 8px; } } `; function openManager() { if (!document.documentElement) { return; } if (state.managerHost) { renderManager(); return; } const uiState = storeGet(STORAGE.UI_STATE, {}) || {}; if ( uiState.viewMode === 'card' || uiState.viewMode === 'list' ) { state.manager.viewMode = uiState.viewMode; } const host = document.createElement('div'); host.id = 'sc-web-recorder-manager-host'; host.dataset.scRecorderUi = 'true'; Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: '2147483646' }); const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = `
`; shadow.addEventListener( 'click', handleManagerClick ); shadow.addEventListener( 'change', handleManagerChange ); shadow.addEventListener( 'input', handleManagerInput ); document.documentElement.appendChild(host); state.managerHost = host; renderManager(); } function closeManager() { state.managerHost?.remove(); state.managerHost = null; } function renderManager() { if (!state.managerHost) { return; } cleanupSelectedRules(); const shadow = state.managerHost.shadowRoot; shadow .querySelectorAll('.tab-button') .forEach(button => { button.classList.toggle( 'active', button.dataset.tab === state.manager.tab ); }); const content = shadow.getElementById( 'manager-content' ); if (state.manager.tab === 'variables') { content.className = 'content-page'; content.innerHTML = renderVariablesPage(); return; } if (state.manager.tab === 'settings') { content.className = 'content-page'; content.innerHTML = renderSettingsPage(); return; } if (state.manager.tab === 'logs') { content.className = 'content-page'; content.innerHTML = renderLogsPage(); return; } content.className = 'workspace'; content.innerHTML = renderRulesPage(); requestAnimationFrame(() => { const searchInput = shadow.getElementById('rule-search'); if (searchInput) { searchInput.value = state.manager.search; } }); } function renderLogsPage() { const logs = getLogs(); const activeFilter = state._logFilter || 'all'; const logsFiltered = activeFilter === 'all' ? logs : logs.filter(l => l.level === activeFilter); const logsReversed = [...logsFiltered].reverse(); let logHtml = ''; if (logsReversed.length === 0) { logHtml = '| 规则 | 分组 | 步骤 | 录制模式 | 变量 | 更新时间 | 操作 | |
|---|---|---|---|---|---|---|---|
|
${escapeHtml(rule.name)}
${
rule.recordingMode === 'detailed'
? '详细录制'
: '简洁录制'
}
${matching
? `
当前页面匹配
`
: ''
}
${escapeHtml(rule.urlPattern)}
|
${rule.steps?.length || 0} | ${ rule.recordingMode === 'detailed' ? '详细录制' : '简洁录制' } | ${(() => { const vars = extractRuleVariables(rule); return vars.length ? vars.map(v => `${escapeHtml(prefix + v + suffix)}`).join(' ') : '-'; })()} | ${escapeHtml( formatTime(rule.updatedAt) )} |
|
执行规则时会先计算固定变量;数据批量中的同名字段具有更高优先级。
| 启用 | 变量名 | 固定值或动态规则 | 下次递增值 | 操作 |
|---|
简洁录制只保留输入框最终值;详细录制会记录更完整的输入、按键和滚动过程。
设置录制结束后规则的命名方式与保存行为。
录制结束后,自动将文本框输入内容替换为变量占位符,方便复用。