// ==UserScript== // @name 可视化自动点击器(多点串行 / 可视化选点) // @namespace https://www.zkcoi.com // @version 1.0.2 // @description 在当前页面可视化拾取多个点击位置,按顺序串行自动点击;每步可单独设置点击间隔、等待超时,支持循环、随机抖动、配置持久化。 // @author zkcoi // @homepage https://www.zkcoi.com // @homepageURL https://www.zkcoi.com // @supportURL https://www.zkcoi.com // @license MIT // @copyright 2026, zkcoi (https://www.zkcoi.com) // @match *://*/* // @noframes // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ============================================================ * 0. 基础 * ========================================================== */ if (window.self !== window.top) return; // 只在主页面运行 const STORE_KEY = 'zkcoi_vac_cfg_v1_' + location.hostname; const LEGACY_KEY = 'vac_cfg_v1_' + location.hostname; // 旧 key,仅用于迁移已有配置 const DEFAULTS = { steps: [], // 步骤列表 loop: 1, // 循环次数,0 = 无限 loopGap: 1000, // 每轮之间的间隔(ms) timeout: 5000, // 等待元素出现的超时(ms) hold: 0, // 按住时长(ms):mousedown 与 mouseup 之间的间隔 jitter: 0 // 随机抖动 ±ms,让节奏更像真人 }; const uid = () => Math.random().toString(36).slice(2, 9); const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms))); const $ = (sel, root) => (root || document).querySelector(sel); // 防御:部分环境/元素可能没有 scrollIntoView,不能让它中断点击流程 const safeScroll = (el, opt) => { try { if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView(opt || { block: 'center' }); } catch (e) { /* 忽略 */ } }; let cfg = loadCfg(); let running = false; let picking = false; let panelHidden = false; let activeId = null; function loadCfg() { try { let raw = GM_getValue(STORE_KEY, null); // 迁移:旧 key 下有配置则接管过来,避免已保存的步骤丢失 if (!raw) { const old = GM_getValue(LEGACY_KEY, null); if (old) { try { GM_setValue(STORE_KEY, old); } catch (e) {} raw = old; } } if (!raw) return JSON.parse(JSON.stringify(DEFAULTS)); const o = JSON.parse(raw); return Object.assign(JSON.parse(JSON.stringify(DEFAULTS)), o, { steps: Array.isArray(o.steps) ? o.steps : [] }); } catch (e) { return JSON.parse(JSON.stringify(DEFAULTS)); } } function save() { try { GM_setValue(STORE_KEY, JSON.stringify(cfg)); } catch (e) { console.warn('[zkcoi] 保存失败', e); } } /* ============================================================ * 1. 选择器生成(尽量短且唯一) * ========================================================== */ function isUnique(sel) { try { return document.querySelectorAll(sel).length === 1; } catch (e) { return false; } } // 安全转义:显式走 window.CSS,避免被同名局部变量遮蔽 const escSel = (s) => { try { return (window.CSS && typeof window.CSS.escape === 'function') ? window.CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c); } catch (e) { return String(s).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c); } }; function cssPath(el) { try { if (!(el instanceof Element)) return ''; // id 优先 if (el.id) { const s = '#' + escSel(el.id); if (isUnique(s)) return s; } const parts = []; let cur = el; for (let depth = 0; cur && cur.nodeType === 1 && depth < 10; depth++) { const parent = cur.parentElement; const tag = cur.tagName.toLowerCase(); const cls = (cur.classList && cur.classList.length) ? [...cur.classList] .filter((c) => c && !/^\d/.test(c) && c.length <= 30 && !/^(hover|active|focus|is-|has-)/.test(c)) .slice(0, 2) .map((c) => '.' + escSel(c)) .join('') : ''; // 先试 tag + class if (cls) { const cand = [tag + cls, ...parts].join(' > '); if (isUnique(cand)) return cand; } // 再试 tag + nth-of-type let nth = ''; if (parent) { const same = [...parent.children].filter((c) => c.tagName === cur.tagName); if (same.length > 1) nth = ':nth-of-type(' + (same.indexOf(cur) + 1) + ')'; } const part = tag + cls + nth; parts.unshift(part); const full = parts.join(' > '); if (isUnique(full)) return full; // 遇到 body/html 或带 id 的祖先,收尾 if (!parent || tag === 'body' || tag === 'html') break; if (cur.id) { parts[0] = '#' + escSel(cur.id); if (isUnique(parts.join(' > '))) return parts.join(' > '); } cur = parent; } return parts.join(' > '); } catch (e) { console.warn('[zkcoi] cssPath 失败', e); return ''; } } function brief(el) { if (!(el instanceof Element)) return '未知元素'; let s = el.tagName.toLowerCase(); if (el.id) s += '#' + el.id; const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 2); if (cls.length) s += '.' + cls.join('.'); const txt = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 18); return txt ? s + ' 「' + txt + '」' : s; } /* ============================================================ * 2. UI 骨架(Shadow DOM,避免与页面样式互相污染) * ========================================================== */ const CSS_TEXT = ` .vac-panel{position:fixed;top:80px;right:20px;width:328px;max-height:76vh;display:flex;flex-direction:column; background:rgba(24,26,32,.96);color:#e8eaf0;border:1px solid rgba(255,255,255,.12);border-radius:14px; box-shadow:0 18px 48px rgba(0,0,0,.45);font:12px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; overflow:hidden;pointer-events:auto;backdrop-filter:blur(8px);user-select:none} .vac-panel.min .vac-body{display:none} .vac-head{display:flex;align-items:center;gap:6px;padding:9px 10px;cursor:move; background:linear-gradient(90deg,#3b6cf6,#8b5cf6);color:#fff;font-weight:600} .vac-head b{font-size:12.5px} .vac-sp{flex:1} .vac-ib{all:unset;cursor:pointer;padding:0 6px;line-height:18px;border-radius:5px;color:inherit;opacity:.85;font-size:12px} .vac-ib:hover{background:rgba(255,255,255,.22);opacity:1} .vac-body{padding:10px;overflow:auto;display:flex;flex-direction:column;gap:8px} .vac-row{display:flex;gap:6px;flex-wrap:wrap} .vac-btn{all:unset;cursor:pointer;padding:6px 10px;border-radius:8px;background:rgba(255,255,255,.09); color:#e8eaf0;border:1px solid rgba(255,255,255,.14);font-size:12px;transition:.15s} .vac-btn:hover{background:rgba(255,255,255,.17)} .vac-btn.primary{background:#3b6cf6;border-color:#3b6cf6;color:#fff} .vac-btn.primary.on{background:#f59e0b;border-color:#f59e0b;animation:vacpulse 1s infinite} .vac-btn.go{background:#16a34a;border-color:#16a34a;color:#fff;flex:1} .vac-btn.runstop{background:#dc2626;border-color:#dc2626} .vac-btn.danger:hover{background:#dc2626;border-color:#dc2626} @keyframes vacpulse{0%,100%{filter:brightness(1)}50%{filter:brightness(1.45)}} .vac-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} .vac-grid label{display:flex;align-items:center;gap:4px;background:rgba(255,255,255,.05); padding:4px 6px;border-radius:7px;color:#b9c0cf;white-space:nowrap} .vac-grid input,.vac-delay{background:rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.16);color:#fff; border-radius:5px;padding:2px 4px;width:56px;font:12px monospace;text-align:right} .vac-delay{width:62px} .vac-grid input:focus,.vac-delay:focus{outline:1px solid #3b6cf6} .vac-tip{color:#7b8496;font-size:10px} .vac-list{display:flex;flex-direction:column;gap:6px;min-height:24px} .vac-empty{color:#7b8496;text-align:center;padding:10px 0;font-size:11.5px;line-height:1.7} .vac-item{display:flex;align-items:center;gap:6px;padding:6px;background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.09);border-radius:9px} .vac-item.active{border-color:#16a34a;background:rgba(22,163,74,.16)} .vac-idx{flex:0 0 20px;height:20px;border-radius:50%;background:#3b6cf6;color:#fff;text-align:center; line-height:20px;font-size:11px;font-weight:700} .vac-item.active .vac-idx{background:#16a34a} .vac-idx.off{background:#6b7280;opacity:.55;text-decoration:line-through} .vac-main{flex:1;min-width:0} .vac-title{font-size:11.5px;color:#e8eaf0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .vac-sub{font-size:10px;color:#7b8496;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .vac-sub .warn{color:#f59e0b} .vac-log{max-height:110px;overflow:auto;font:10.5px/1.6 ui-monospace,Menlo,Consolas,monospace; color:#9aa4b8;background:rgba(0,0,0,.3);border-radius:7px;padding:6px; user-select:text;-webkit-user-select:text;cursor:text;white-space:pre-wrap;word-break:break-all} .vac-log.errbox{border:1px solid #dc2626} .vac-log div{border-bottom:1px dotted rgba(255,255,255,.06);padding:1px 0} .vac-log .err{color:#f87171} .vac-log .ok{color:#4ade80} .vac-io{width:100%;height:70px;background:rgba(0,0,0,.4);color:#c9d2e3;border:1px solid rgba(255,255,255,.14); border-radius:7px;font:10.5px monospace;padding:5px;resize:vertical} /* 页面上的编号徽标 */ .vac-badge{position:fixed;left:0;top:0;transform:translate(-50%,-50%);width:22px;height:22px;border-radius:50%; background:#3b6cf6;color:#fff;font:700 11px/22px sans-serif;text-align:center;cursor:pointer; pointer-events:auto;box-shadow:0 2px 8px rgba(0,0,0,.5);border:2px solid #fff;z-index:2147483646} .vac-badge:hover{background:#8b5cf6;transform:translate(-50%,-50%) scale(1.15)} .vac-badge.miss{background:#6b7280;opacity:.55} .vac-badge.active{background:#16a34a;animation:vacpop .6s infinite} @keyframes vacpop{0%,100%{box-shadow:0 0 0 0 rgba(22,163,74,.7)}50%{box-shadow:0 0 0 10px rgba(22,163,74,0)}} .vac-badge.coord{border-radius:4px;background:#f59e0b} /* 元素高亮框 */ .vac-hl{position:fixed;pointer-events:none;border:2px solid #f59e0b;background:rgba(245,158,11,.16); border-radius:3px;z-index:2147483645;display:none;transition:all .06s linear} .vac-hl-tag{position:absolute;left:0;top:-20px;background:#f59e0b;color:#111;font:11px/16px sans-serif; padding:0 6px;border-radius:4px;white-space:nowrap;max-width:320px;overflow:hidden;text-overflow:ellipsis} /* 拾取提示条 / 闪烁圈 / 气泡菜单 */ .vac-tipbar{position:fixed;left:50%;top:14px;transform:translateX(-50%);background:#111827ee;color:#fff; padding:8px 16px;border-radius:999px;font:12px sans-serif;z-index:2147483647;display:none; border:1px solid rgba(255,255,255,.2);box-shadow:0 8px 24px rgba(0,0,0,.5)} .vac-flash{position:fixed;pointer-events:none;border-radius:50%;width:36px;height:36px;z-index:2147483644; border:3px solid #16a34a;animation:vacring .55s ease-out forwards} @keyframes vacring{from{transform:scale(.3);opacity:1}to{transform:scale(2.6);opacity:0}} .vac-menu{position:fixed;z-index:2147483647;background:rgba(24,26,32,.98);border:1px solid rgba(255,255,255,.16); border-radius:10px;padding:8px;display:flex;flex-direction:column;gap:6px;pointer-events:auto; font:12px sans-serif;color:#e8eaf0;box-shadow:0 12px 32px rgba(0,0,0,.5);width:210px} .vac-menu .t{font-size:11px;color:#9aa4b8;word-break:break-all;max-height:56px;overflow:auto} .vac-menu .r{display:flex;gap:6px;align-items:center} .vac-menu button{all:unset;cursor:pointer;flex:1;text-align:center;padding:5px;border-radius:6px; background:rgba(255,255,255,.1);font-size:11.5px} .vac-menu button:hover{background:rgba(255,255,255,.2)} .vac-menu button.del:hover{background:#dc2626} `; const host = document.createElement('div'); host.style.cssText = 'all:initial;position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;'; const sr = host.attachShadow({ mode: 'open' }); sr.innerHTML = `
🎯 点击页面元素即可添加为点击步骤 · Esc 取消拾取
`; document.documentElement.appendChild(host); const P = (id) => sr.getElementById(id); const panel = P('panel'), listEl = P('list'), logEl = P('log'), hl = P('hl'), hlTag = P('hlTag'), tipbar = P('tipbar'), badgesEl = P('badges'), bodyEl = P('body'); /* ============================================================ * 3. 日志 / 渲染 * ========================================================== */ function log(msg, cls) { if (!msg && msg !== 0) return; const d = document.createElement('div'); if (cls) d.className = cls; const t = new Date(); const ts = String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0') + ':' + String(t.getSeconds()).padStart(2, '0'); d.textContent = '[' + ts + '] ' + msg; logEl.appendChild(d); while (logEl.children.length > 100) logEl.removeChild(logEl.firstChild); logEl.scrollTop = logEl.scrollHeight; if (cls === 'err') logEl.classList.add('errbox'); // 出现错误时给日志框描红,方便察觉 } function render() { listEl.innerHTML = ''; if (!cfg.steps.length) { listEl.innerHTML = '
还没有点击步骤
点「🎯 拾取位置」后在页面上点选目标元素
'; } cfg.steps.forEach((s, i) => { const el = document.createElement('div'); el.className = 'vac-item' + (activeId === s.id ? ' active' : ''); el.dataset.id = s.id; const ok = s.mode === 'selector' ? !!document.querySelector(s.selector) : true; el.innerHTML = '' + (i + 1) + '' + '
' + '
' + esc(s.name || '步骤') + '
' + '
' + (s.mode === 'selector' ? (ok ? '选择器定位' : '当前页面未匹配') : '坐标定位 ' + Math.round(s.xPct * 100) + '%,' + Math.round(s.yPct * 100) + '%') + '
' + '
' + '' + '' + '' + ''; listEl.appendChild(el); }); renderBadges(); } function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } const badgeMap = new Map(); function renderBadges() { badgesEl.innerHTML = ''; badgeMap.clear(); cfg.steps.forEach((s, i) => { const b = document.createElement('div'); b.className = 'vac-badge' + (s.mode === 'coord' ? ' coord' : '') + (activeId === s.id ? ' active' : ''); b.textContent = String(i + 1); b.title = (s.name || '') + ' · 间隔 ' + s.delay + 'ms(点击可编辑)'; b.addEventListener('click', (e) => { e.stopPropagation(); openMenu(s, b); }); badgesEl.appendChild(b); badgeMap.set(s.id, b); }); placeBadges(); } function placeBadges() { cfg.steps.forEach((s) => { const b = badgeMap.get(s.id); if (!b) return; let x, y, vis = true; const bySel = s.mode === 'selector' && s.selector && document.querySelector(s.selector); if (bySel) { const r = document.querySelector(s.selector).getBoundingClientRect(); x = r.left + Math.min(r.width / 2, 24); y = r.top + Math.min(r.height / 2, 24); vis = r.width > 0 && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; } else { x = s.xPct * innerWidth; y = s.yPct * innerHeight; } b.style.left = Math.max(8, Math.min(innerWidth - 8, x)) + 'px'; b.style.top = Math.max(8, Math.min(innerHeight - 8, y)) + 'px'; b.classList.toggle('miss', !bySel && s.mode === 'selector'); b.style.opacity = vis ? '' : '0.3'; }); } setInterval(placeBadges, 400); addEventListener('scroll', placeBadges, true); addEventListener('resize', placeBadges); function syncInputs() { P('i-loop').value = cfg.loop; P('i-gap').value = cfg.loopGap; P('i-timeout').value = cfg.timeout; P('i-hold').value = cfg.hold; P('i-jitter').value = cfg.jitter; } /* ============================================================ * 4. 步骤编辑 * ========================================================== */ function addStepFromElement(el, cx, cy) { let sel = ''; try { sel = cssPath(el); } catch (e) { sel = ''; } const ok = !!sel && isUnique(sel); let nm = '元素'; try { nm = brief(el); } catch (e) {} const s = { id: uid(), name: nm, mode: ok ? 'selector' : 'coord', selector: ok ? sel : '', xPct: +(cx / innerWidth).toFixed(4), yPct: +(cy / innerHeight).toFixed(4), delay: cfg.steps.length ? cfg.steps[cfg.steps.length - 1].delay : 1000, enabled: true }; cfg.steps.push(s); save(); render(); log('已添加 #' + cfg.steps.length + ' ' + s.name + (ok ? '' : '(选择器不稳定→坐标)'), 'ok'); flash(el); return s; } listEl.addEventListener('click', (e) => { const btn = e.target.closest('[data-a]'); const item = e.target.closest('.vac-item'); if (!item) return; const s = cfg.steps.find((x) => x.id === item.dataset.id); if (!s) return; if (!btn) return; const a = btn.dataset.a; const i = cfg.steps.indexOf(s); if (a === 'del') { cfg.steps.splice(i, 1); } else if (a === 'toggle') { s.enabled = s.enabled === false; } else if (a === 'up') { if (i > 0) { cfg.steps.splice(i, 1); cfg.steps.splice(i - 1, 0, s); } } else if (a === 'test') { log('试点击 #' + (i + 1)); runOnce(s); } save(); render(); }); listEl.addEventListener('change', (e) => { if (!e.target.classList.contains('vac-delay')) return; const item = e.target.closest('.vac-item'); const s = cfg.steps.find((x) => x.id === item.dataset.id); if (!s) return; s.delay = Math.max(0, parseInt(e.target.value, 10) || 0); save(); renderBadges(); }); // 徽标气泡菜单 let menuEl = null; function closeMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } } function openMenu(s, anchor) { closeMenu(); const r = anchor.getBoundingClientRect(); const m = document.createElement('div'); m.className = 'vac-menu'; m.innerHTML = '
#' + (cfg.steps.indexOf(s) + 1) + ' ' + esc(s.name || '') + '
' + (s.mode === 'selector' ? esc(s.selector) : '坐标 ' + Math.round(s.xPct * 100) + '%,' + Math.round(s.yPct * 100) + '%') + '
' + '
间隔' + 'ms
' + '
' + '
' + '
'; sr.appendChild(m); menuEl = m; const w = 210, h = m.offsetHeight || 150; m.style.left = Math.max(8, Math.min(innerWidth - w - 8, r.left - w / 2)) + 'px'; m.style.top = Math.max(8, Math.min(innerHeight - h - 8, r.bottom + 6)) + 'px'; m.addEventListener('click', (e) => e.stopPropagation()); m.addEventListener('change', (e) => { if (e.target.tagName === 'INPUT') { s.delay = Math.max(0, parseInt(e.target.value, 10) || 0); save(); render(); } }); m.addEventListener('click', (e) => { const b = e.target.closest('[data-m]'); if (!b) return; const k = b.dataset.m; if (k === 'del') { const i = cfg.steps.indexOf(s); cfg.steps.splice(i, 1); save(); render(); closeMenu(); } else if (k === 'test') { runOnce(s); } else if (k === 'locate') { locate(s); } else if (k === 'swap') { if (s.mode === 'selector') { const el = document.querySelector(s.selector); if (el) { const r2 = el.getBoundingClientRect(); s.xPct = +((r2.left + r2.width / 2) / innerWidth).toFixed(4); s.yPct = +((r2.top + r2.height / 2) / innerHeight).toFixed(4); } s.mode = 'coord'; } else { const el = document.elementFromPoint(s.xPct * innerWidth, s.yPct * innerHeight); const sel = el ? cssPath(el) : ''; if (el && isUnique(sel)) { s.selector = sel; s.mode = 'selector'; s.name = brief(el); } else { log('该坐标下无法生成唯一选择器,保持坐标模式', 'err'); } } save(); render(); closeMenu(); } }); } addEventListener('click', closeMenu, true); addEventListener('scroll', closeMenu, true); function locate(s) { const el = s.mode === 'selector' ? document.querySelector(s.selector) : null; if (el) { safeScroll(el, { block: 'center', behavior: 'smooth' }); flash(el); } else flashAt(s.xPct * innerWidth, s.yPct * innerHeight); } /* ============================================================ * 5. 可视化拾取 * ========================================================== */ // 十字光标:注入到主文档,给拾取模式一个明确的视觉反馈 function setCursor(on) { let st = document.getElementById('vac-cursor-style'); if (on) { if (!st) { st = document.createElement('style'); st.id = 'vac-cursor-style'; document.head.appendChild(st); } st.textContent = 'html.vac-picking,html.vac-picking *{cursor:crosshair !important;}'; document.documentElement.classList.add('vac-picking'); } else { document.documentElement.classList.remove('vac-picking'); if (st) st.remove(); } } function setPick(on) { picking = on; P('btn-pick').classList.toggle('on', on); P('btn-pick').textContent = on ? '🛑 停止拾取' : '🎯 拾取位置'; tipbar.style.display = on ? 'block' : 'none'; hl.style.display = on ? 'none' : 'none'; setCursor(on); if (on) log('拾取中:移动鼠标→橙色高亮框;点击→添加为步骤(按住 Shift 连续拾取,Esc 取消)', 'ok'); } function onMove(e) { if (!picking) return; try { const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || el === host || host.contains(el)) { hl.style.display = 'none'; return; } const r = el.getBoundingClientRect(); if (!r.width && !r.height) { hl.style.display = 'none'; return; } hl.style.display = 'block'; hl.style.left = r.left + 'px'; hl.style.top = r.top + 'px'; hl.style.width = r.width + 'px'; hl.style.height = r.height + 'px'; hlTag.textContent = brief(el) + ' ' + Math.round(r.width) + '×' + Math.round(r.height); } catch (err) { console.warn('[zkcoi] onMove', err); } } function onDown(e) { if (!picking) return; const el = document.elementFromPoint(e.clientX, e.clientY); if (el && el !== host && !host.contains(el)) { e.preventDefault(); e.stopPropagation(); } } function onClickCap(e) { if (!picking) return; let el = null; try { el = document.elementFromPoint(e.clientX, e.clientY); } catch (err) {} e.preventDefault(); e.stopPropagation(); if (!el || el === host || host.contains(el)) return; try { if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') { log('⚠ 该位置是 iframe 内嵌页面,脚本无法直接操作内部元素', 'err'); flashAt(e.clientX, e.clientY); return; } const before = cfg.steps.length; addStepFromElement(el, e.clientX, e.clientY); if (cfg.steps.length === before) log('⚠ 添加失败,请检查控制台', 'err'); } catch (err) { log('⚠ 拾取异常:' + (err && err.message ? err.message : err), 'err'); console.error('[zkcoi] 拾取异常', err); } if (!e.shiftKey) setPick(false); // 按住 Shift 可连续拾取多个 } function onKey(e) { if (e.key === 'Escape') { if (picking) { setPick(false); log('已取消拾取'); } closeMenu(); } if (e.altKey && (e.key === 'p' || e.key === 'P')) { toggleHide(); e.preventDefault(); } } document.addEventListener('mousemove', onMove, true); document.addEventListener('mousedown', onDown, true); document.addEventListener('click', onClickCap, true); document.addEventListener('keydown', onKey, true); /* ============================================================ * 6. 点击执行引擎 * ========================================================== */ async function isleep(ms) { const end = Date.now() + ms; while (running && Date.now() < end) await sleep(Math.min(120, end - Date.now())); } function waitFor(sel, timeout) { return new Promise((res) => { const t0 = Date.now(); const t = setInterval(() => { const el = document.querySelector(sel); if (el) { clearInterval(t); res(el); } else if (!running || Date.now() - t0 > timeout) { clearInterval(t); res(null); } }, 120); }); } // 取真实 Window:用户脚本沙箱里的 window 往往是代理对象, // 直接塞进 MouseEvent 的 view 字段会抛 "Failed to convert value to 'Window'" function realWindow() { try { return (document && document.defaultView) || (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; } catch (e) { return window; } } function fire(el, type, cx, cy) { if (!el) return false; const vw = realWindow(); const sx = cx + ((vw && typeof vw.screenX === 'number') ? vw.screenX : 0); const sy = cy + ((vw && typeof vw.screenY === 'number') ? vw.screenY : 0); const base = { bubbles: true, cancelable: true, composed: true, clientX: cx, clientY: cy, screenX: sx, screenY: sy, button: 0, buttons: (type === 'mouseup' || type === 'click') ? 0 : 1, detail: 1 }; // 逐级降级构造,任一种成功即可:带 view 的 Pointer → 不带 view 的 Pointer → 带 view 的 Mouse → 不带 view → 老式 initMouseEvent const attempts = []; if (type.indexOf('pointer') === 0 && typeof PointerEvent === 'function') { const pe = { pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1 }; attempts.push(() => new PointerEvent(type, Object.assign({}, base, { view: vw }, pe))); attempts.push(() => new PointerEvent(type, Object.assign({}, base, pe))); } if (typeof MouseEvent === 'function') { attempts.push(() => new MouseEvent(type, Object.assign({}, base, { view: vw }))); attempts.push(() => new MouseEvent(type, base)); } attempts.push(() => { const e = document.createEvent('MouseEvents'); e.initMouseEvent(type, true, true, vw, 1, sx, sy, cx, cy, false, false, false, false, 0, null); return e; }); let ev = null, lastErr = null; for (let i = 0; i < attempts.length; i++) { try { ev = attempts[i](); if (ev) break; } catch (e) { lastErr = e; ev = null; } } if (!ev) { const m = (lastErr && lastErr.message) ? lastErr.message : '未知原因'; if (typeof log === 'function') log('⚠ 事件构造失败(' + type + '):' + m, 'err'); return false; } try { el.dispatchEvent(ev); return true; } catch (e) { if (typeof log === 'function') log('⚠ 事件派发失败:' + (e.message || e), 'err'); return false; } } async function clickEl(el, s) { if (!el) return; const r = el.getBoundingClientRect(); let cx, cy; if (s && s.mode === 'coord') { cx = s.xPct * innerWidth; cy = s.yPct * innerHeight; } else { cx = r.left + Math.min(r.width / 2, 30); cy = r.top + Math.min(r.height / 2, 30); } fire(el, 'pointerover', cx, cy); fire(el, 'mouseover', cx, cy); fire(el, 'pointermove', cx, cy); fire(el, 'mousemove', cx, cy); fire(el, 'pointerdown', cx, cy); fire(el, 'mousedown', cx, cy); if (typeof el.focus === 'function' && el.tabIndex >= 0) { try { el.focus(); } catch (e) {} } if (cfg.hold > 0) await sleep(cfg.hold); fire(el, 'pointerup', cx, cy); fire(el, 'mouseup', cx, cy); fire(el, 'click', cx, cy); } function flash(el) { if (!el) return; const r = el.getBoundingClientRect(); flashAt(r.left + Math.min(r.width / 2, 30), r.top + Math.min(r.height / 2, 30)); } function flashAt(x, y) { const d = document.createElement('div'); d.className = 'vac-flash'; d.style.left = (x - 18) + 'px'; d.style.top = (y - 18) + 'px'; sr.appendChild(d); setTimeout(() => d.remove(), 600); } async function runOnce(s) { let el = null; if (s.mode === 'selector' && s.selector) el = await waitFor(s.selector, Math.min(cfg.timeout, 2000)); if (!el) el = document.elementFromPoint(s.xPct * innerWidth, s.yPct * innerHeight); if (!el) { log('试点击失败:未找到目标', 'err'); return; } safeScroll(el, { block: 'center' }); flash(el); await clickEl(el, s); log('试点击完成', 'ok'); } function setActive(id) { activeId = id; badgeMap.forEach((b, k) => b.classList.toggle('active', k === id)); [...listEl.children].forEach((it) => it.classList.toggle('active', it.dataset.id === id)); } async function start() { if (running) { stop(); return; } const honor = P('i-skip').checked; // 勾选时跳过被禁用的步骤 const actives = cfg.steps.filter((s) => !(honor && s.enabled === false)); if (!actives.length) { log('没有可执行的步骤', 'err'); return; } running = true; P('btn-run').textContent = '⏹ 停止'; P('btn-run').classList.add('runstop'); log('▶ 开始执行,共 ' + actives.length + ' 步 / ' + (cfg.loop === 0 ? '无限循环' : cfg.loop + ' 轮')); let n = 0; outer: while (running && (cfg.loop === 0 || n < cfg.loop)) { n++; if (cfg.loop !== 1) log('—— 第 ' + n + ' 轮 ——'); for (let i = 0; i < cfg.steps.length; i++) { if (!running) break outer; const s = cfg.steps[i]; if (honor && s.enabled === false) continue; setActive(s.id); // 间隔 = 本步 delay + 随机抖动 const jit = cfg.jitter ? Math.round((Math.random() * 2 - 1) * cfg.jitter) : 0; await isleep(Math.max(0, s.delay + jit)); if (!running) break outer; let el = null; if (s.mode === 'selector' && s.selector) el = await waitFor(s.selector, cfg.timeout); if (!el && s.mode === 'selector') { el = document.elementFromPoint(s.xPct * innerWidth, s.yPct * innerHeight); if (el) log('#' + (i + 1) + ' 选择器未命中,回退坐标点击'); } if (!el) { log('#' + (i + 1) + ' 等待超时,跳过', 'err'); continue; } safeScroll(el, { block: 'center', inline: 'center' }); await sleep(120); flash(el); await clickEl(el, s); log('#' + (i + 1) + ' 已点击 ' + (s.name || '').slice(0, 20), 'ok'); } if (!running) break; if (cfg.loop === 0 || n < cfg.loop) await isleep(cfg.loopGap); } running = false; setActive(null); P('btn-run').textContent = '▶ 运行'; P('btn-run').classList.remove('runstop'); log('⏹ 已停止'); } function stop() { running = false; setActive(null); } /* ============================================================ * 7. 面板交互(拖动 / 按钮 / 配置导入导出) * ========================================================== */ (function drag() { let sx = 0, sy = 0, sl = 0, st = 0, dragging = false; P('head').addEventListener('mousedown', (e) => { if (e.target.closest('.vac-ib')) return; dragging = true; const r = panel.getBoundingClientRect(); panel.style.right = 'auto'; panel.style.left = r.left + 'px'; panel.style.top = r.top + 'px'; sl = r.left; st = r.top; sx = e.clientX; sy = e.clientY; e.preventDefault(); }); addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = Math.max(0, Math.min(innerWidth - 60, sl + e.clientX - sx)) + 'px'; panel.style.top = Math.max(0, Math.min(innerHeight - 30, st + e.clientY - sy)) + 'px'; }); addEventListener('mouseup', () => { dragging = false; }); })(); function toggleHide() { panelHidden = !panelHidden; panel.style.display = panelHidden ? 'none' : 'flex'; if (!panelHidden) { panel.style.right = panel.style.right || '20px'; } } P('btn-hide').addEventListener('click', () => { toggleHide(); log(panelHidden ? '面板已隐藏,Alt+P 唤出' : ''); }); P('btn-min').addEventListener('click', () => panel.classList.toggle('min')); P('btn-pick').addEventListener('click', () => { if (picking) { setPick(false); log('已停止拾取'); return; } panel.classList.remove('min'); setPick(true); }); P('btn-add').addEventListener('click', () => { const sel = prompt('输入 CSS 选择器(留空则用屏幕中心坐标):\n例如:#submit 或 button.primary', ''); if (sel === null) return; const s = { id: uid(), name: sel || '坐标点', mode: 'selector', selector: sel.trim(), xPct: 0.5, yPct: 0.5, delay: 1000, enabled: true }; if (!sel.trim()) s.mode = 'coord'; cfg.steps.push(s); save(); render(); log('已手动添加 #' + cfg.steps.length, 'ok'); }); P('btn-run').addEventListener('click', start); P('btn-clear').addEventListener('click', () => { if (!confirm('确定清空当前站点(' + location.hostname + ')的所有步骤?')) return; cfg.steps = []; save(); render(); log('已清空'); }); P('btn-export').addEventListener('click', () => { copyText(JSON.stringify(cfg, null, 2), '配置 JSON 已复制到剪贴板'); }); P('btn-import').addEventListener('click', () => { const txt = prompt('粘贴配置 JSON:'); if (!txt) return; try { const o = JSON.parse(txt); if (!Array.isArray(o.steps)) throw new Error('缺少 steps'); cfg = Object.assign(JSON.parse(JSON.stringify(DEFAULTS)), o); cfg.steps.forEach((s) => { if (!s.id) s.id = uid(); }); save(); syncInputs(); render(); log('已导入 ' + cfg.steps.length + ' 个步骤', 'ok'); } catch (e) { log('导入失败:' + e.message, 'err'); } }); // 统一复制:优先剪贴板 API,失败则回退 execCommand,再失败就弹出可手动选择的文本域 async function copyText(text, okMsg) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); log(okMsg || '已复制到剪贴板', 'ok'); return true; } } catch (e) { /* 继续回退 */ } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length); const ok = document.execCommand('copy'); ta.remove(); if (ok) { log(okMsg || '已复制到剪贴板', 'ok'); return true; } } catch (e) { /* 继续回退 */ } // 最后手段:在面板里显示可框选的文本域 let box = sr.querySelector('.vac-io'); if (!box) { box = document.createElement('textarea'); box.className = 'vac-io'; bodyEl.appendChild(box); } box.value = text; box.style.height = '110px'; box.focus(); box.select(); log('自动复制失败,请在下方文本框手动复制(Ctrl+C)', 'err'); return false; } P('btn-copylog').addEventListener('click', () => { const lines = [...logEl.children].map((d) => d.textContent).join('\n'); if (!lines.trim()) { log('日志为空'); return; } const header = '# VAC 日志 ' + new Date().toLocaleString() + '\n# 页面 ' + location.href + '\n'; copyText(header + lines, '日志已复制(共 ' + logEl.children.length + ' 行)'); }); P('btn-clearlog').addEventListener('click', () => { logEl.innerHTML = ''; logEl.classList.remove('errbox'); log('日志已清空'); }); ['i-loop:i:loop', 'i-gap:i:loopGap', 'i-timeout:i:timeout', 'i-hold:i:hold', 'i-jitter:i:jitter'].forEach((spec) => { const [id, , key] = spec.split(':'); P(id).addEventListener('change', (e) => { cfg[key] = Math.max(0, parseInt(e.target.value, 10) || 0); save(); }); }); /* ============================================================ * 8. 启动 * ========================================================== */ // 错误可视化:拾取/运行期间的错误直接写进面板日志,不用开控制台也能看见 addEventListener('error', (e) => { if (!picking && !running) return; if (e && e.message) log('⚠ 错误:' + e.message + '(可用「📋 复制日志」复制给我)', 'err'); }); addEventListener('unhandledrejection', (e) => { if (!picking && !running) return; const r = e && e.reason; log('⚠ 异常:' + (r && r.message ? r.message : String(r)) + '(可用「📋 复制日志」复制给我)', 'err'); }); syncInputs(); render(); log('v1.0.2 已就绪 · Alt+P 显隐面板 · 配置按站点自动保存(' + location.hostname + ')'); if (cfg.steps.length) log('已恢复上次保存的 ' + cfg.steps.length + ' 个步骤'); console.log('%c[zkcoi] 可视化自动点击器 v1.0.2 · https://www.zkcoi.com', 'color:#3b6cf6;font-weight:bold'); try { GM_registerMenuCommand('显示/隐藏面板', toggleHide); GM_registerMenuCommand('开始/停止运行', () => start()); GM_registerMenuCommand('拾取点击位置 (备用入口)', () => setPick(!picking)); GM_registerMenuCommand('清空日志/重置面板位置', () => { logEl.innerHTML = ''; panel.style.left = 'auto'; panel.style.right = '20px'; panel.style.top = '80px'; panel.style.display = 'flex'; panelHidden = false; log('面板已复位'); }); } catch (e) {} })();