Files
2026-09-20 13:55:46 +08:00

858 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 = `
<style>${CSS_TEXT}</style>
<div class="vac-panel" id="panel">
<div class="vac-head" id="head" title="by zkcoi · https://www.zkcoi.com">
<span>🎯</span><b>可视化自动点击器</b><span class="vac-sp"></span>
<button class="vac-ib" id="btn-min" title="折叠/展开">—</button>
<button class="vac-ib" id="btn-hide" title="隐藏面板 (Alt+P)">×</button>
</div>
<div class="vac-body" id="body">
<div class="vac-row">
<button class="vac-btn primary" id="btn-pick">🎯 拾取位置</button>
<button class="vac-btn" id="btn-add" title="手动输入 CSS 选择器">+</button>
<button class="vac-btn go" id="btn-run">▶ 运行</button>
</div>
<div class="vac-grid">
<label>循环<input id="i-loop" type="number" min="0" step="1"><span class="vac-tip">0=∞</span></label>
<label>轮间隔<input id="i-gap" type="number" min="0" step="100">ms</label>
<label>超时<input id="i-timeout" type="number" min="0" step="500">ms</label>
<label>按住<input id="i-hold" type="number" min="0" step="10">ms</label>
<label>抖动±<input id="i-jitter" type="number" min="0" step="50">ms</label>
<label>仅启用项<input id="i-skip" type="checkbox" checked title="跳过未勾选的步骤"></label>
</div>
<div class="vac-list" id="list"></div>
<div class="vac-row">
<button class="vac-btn" id="btn-export">导出</button>
<button class="vac-btn" id="btn-import">导入</button>
<button class="vac-btn danger" id="btn-clear">清空</button>
</div>
<div class="vac-log" id="log" title="可直接框选复制;下方按钮可一键复制全部"></div>
<div class="vac-row">
<button class="vac-btn" id="btn-copylog">📋 复制日志</button>
<button class="vac-btn danger" id="btn-clearlog">清空日志</button>
</div>
</div>
</div>
<div class="vac-hl" id="hl"><div class="vac-hl-tag" id="hlTag"></div></div>
<div class="vac-tipbar" id="tipbar">🎯 点击页面元素即可添加为点击步骤 · <b>Esc</b> 取消拾取</div>
<div id="badges"></div>
`;
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 = '<div class="vac-empty">还没有点击步骤<br>点「🎯 拾取位置」后在页面上点选目标元素</div>';
}
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 =
'<span class="vac-idx' + (s.enabled === false ? ' off' : '') + '" data-a="toggle" title="点击启用/禁用该步骤">' + (i + 1) + '</span>' +
'<div class="vac-main">' +
'<div class="vac-title" title="' + esc(s.name || '') + '">' + esc(s.name || '步骤') + '</div>' +
'<div class="vac-sub">' + (s.mode === 'selector'
? (ok ? '选择器定位' : '<span class="warn">当前页面未匹配</span>')
: '坐标定位 ' + Math.round(s.xPct * 100) + '%,' + Math.round(s.yPct * 100) + '%') +
'</div>' +
'</div>' +
'<input class="vac-delay" type="number" min="0" step="100" value="' + s.delay + '" title="点击间隔:上一步完成后等待多少毫秒再点这一步">' +
'<button class="vac-ib" data-a="test" title="试点击一次">▶</button>' +
'<button class="vac-ib" data-a="up" title="上移">↑</button>' +
'<button class="vac-ib" data-a="del" title="删除">✕</button>';
listEl.appendChild(el);
});
renderBadges();
}
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 =
'<div class="t">#' + (cfg.steps.indexOf(s) + 1) + ' ' + esc(s.name || '') +
'<br><span style="color:#7b8496">' + (s.mode === 'selector' ? esc(s.selector) : '坐标 ' + Math.round(s.xPct * 100) + '%,' + Math.round(s.yPct * 100) + '%') + '</span></div>' +
'<div class="r"><span style="flex:0 0 auto;color:#9aa4b8">间隔</span>' +
'<input class="vac-delay" style="flex:1;width:auto" type="number" min="0" step="100" value="' + s.delay + '"><span style="color:#9aa4b8">ms</span></div>' +
'<div class="r"><button data-m="test">试点击</button><button data-m="locate">定位</button></div>' +
'<div class="r"><button data-m="swap">' + (s.mode === 'selector' ? '改为坐标' : '改为选择器') + '</button>' +
'<button class="del" data-m="del">删除</button></div>';
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) {}
})();