上传文件至「/」
This commit is contained in:
commit
42fef77e18
2 files changed
+1073
No files matched your search
@@ -0,0 +1,216 @@
|
||||
# 可视化自动点击器
|
||||
|
||||
[](https://www.zkcoi.com)
|
||||
[](./LICENSE)
|
||||
[](https://www.tampermonkey.net/)
|
||||
|
||||
一个 Tampermonkey 用户脚本:在任意网页上**可视化拾取**多个点击位置,按设定顺序**串行自动点击**,每一步可单独设置**点击间隔**,支持循环、随机抖动与配置持久化。
|
||||
|
||||
- 🌐 官网:<https://www.zkcoi.com>
|
||||
- 👤 作者:zkcoi
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
| --- | --- |
|
||||
| 🎯 **可视化选点** | 开启拾取后鼠标变十字光标,悬停元素实时高亮并显示元素标签,点击即添加为步骤 |
|
||||
| 🔢 **多点串行** | 支持任意多个点击位置,严格按列表顺序依次执行 |
|
||||
| ⏱ **逐步骤间隔** | 每个步骤可单独设置点击间隔(毫秒),互不影响 |
|
||||
| 📍 **双定位模式** | 优先生成唯一 CSS 选择器(元素移动也不丢);生成失败自动回退为屏幕比例坐标 |
|
||||
| 🏷 **页面编号标记** | 每个步骤在页面上对应位置显示编号徽标,随滚动/元素移动自动跟随,点击可编辑 |
|
||||
| 🔁 **循环执行** | 可设置循环次数(`0` = 无限),并设置每轮之间的间隔 |
|
||||
| 🎲 **随机抖动** | 让间隔随机浮动,模拟真人节奏,降低被自动化检测识别的概率 |
|
||||
| 🖱 **按住时长** | 可设置 mousedown 与 mouseup 之间的间隔,应对需要"长按"的组件 |
|
||||
| 💾 **配置持久化** | 按域名自动保存,下次打开页面自动恢复;支持导出/导入 JSON |
|
||||
| 🛡 **异常自愈** | 事件构造五级降级 + 全流程错误捕获,异常直接显示在面板日志中 |
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式一:从 Gitea 直接安装(推荐)
|
||||
|
||||
1. 浏览器安装 [Tampermonkey](https://www.tampermonkey.net/) 扩展
|
||||
2. 打开本仓库的 `可视化自动点击器.user.js` 文件,点击「Raw 原始文件」
|
||||
3. Tampermonkey 会自动识别并弹出安装页面,点击「安装」即可
|
||||
4. 后续 Tampermonkey 会自动检测更新
|
||||
|
||||
### 方式二:手动粘贴
|
||||
|
||||
1. Tampermonkey → 仪表盘 → 「+」新建脚本
|
||||
2. 删除默认内容,粘贴 `可视化自动点击器.user.js` 的全部内容
|
||||
3. `Ctrl + S` 保存,**刷新目标页面**生效
|
||||
|
||||
> ⚠️ 更新脚本后,务必刷新已打开的页面,否则内存中运行的仍是旧版本。
|
||||
|
||||
---
|
||||
|
||||
## 使用教程
|
||||
|
||||
### 1. 拾取点击位置
|
||||
|
||||
1. 点击面板上的 **「🎯 拾取位置」** 按钮
|
||||
2. 鼠标指针变为 **十字光标**,页面顶部出现提示条
|
||||
3. 移动鼠标 → 目标元素出现 **橙色高亮框**,框上标签显示元素类型与尺寸
|
||||
4. 单击左键 → 该位置被添加为一步,元素上闪一圈绿光
|
||||
|
||||
| 操作 | 效果 |
|
||||
| --- | --- |
|
||||
| 单击 | 拾取一个位置后自动退出拾取模式 |
|
||||
| 按住 `Shift` 点击 | 连续拾取多个位置,不退出 |
|
||||
| `Esc` | 取消拾取 |
|
||||
|
||||
### 2. 设置间隔与参数
|
||||
|
||||
- **每步间隔**:步骤列表右侧的数字输入框,含义是"上一步完成后,等待多少毫秒再点击这一步",单位毫秒
|
||||
- **全局参数**:循环次数、轮间隔、等待超时、按住时长、抖动范围
|
||||
|
||||
### 3. 运行
|
||||
|
||||
- 点击 **「▶ 运行」** 开始串行执行,当前执行的步骤徽标会绿色脉冲
|
||||
- 再次点击(按钮变为 **「⏹ 停止」**)即可随时停止
|
||||
|
||||
### 快捷键与菜单
|
||||
|
||||
| 操作 | 说明 |
|
||||
| --- | --- |
|
||||
| `Alt + P` | 显示 / 隐藏面板 |
|
||||
| `Esc` | 取消拾取 / 关闭弹出菜单 |
|
||||
| 脚本菜单 | Tampermonkey 图标 → 显示面板 / 开始停止 / 拾取位置 / 重置面板 |
|
||||
|
||||
---
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 全局参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 循环 | `1` | 执行轮数,`0` 表示无限循环 |
|
||||
| 轮间隔 | `1000` ms | 每轮结束后等待多久开始下一轮 |
|
||||
| 超时 | `5000` ms | 等待目标元素出现的最大时间,超时则跳过该步 |
|
||||
| 按住 | `0` ms | mousedown 与 mouseup 之间的间隔,模拟长按 |
|
||||
| 抖动 | `0` ms | 每步间隔的随机浮动范围,详见下文 |
|
||||
| 仅启用项 | 勾选 | 跳过在列表中被禁用(编号划掉)的步骤 |
|
||||
|
||||
### 抖动(Jitter)是怎么算的
|
||||
|
||||
```
|
||||
实际等待 = 该步基础间隔 + random(-抖动, +抖动)
|
||||
```
|
||||
|
||||
示例:基础间隔 `1000ms`、抖动 `200ms` → 实际等待可能是 `820ms`、`1130ms`、`960ms`……
|
||||
|
||||
- 每一步**独立随机**,不是全程只摇一次
|
||||
- 结果做了 `Math.max(0, ...)` 保底,不会出现负数
|
||||
- 只影响"等待多久",**不改变点击坐标,也不会跳过步骤**
|
||||
|
||||
**推荐取值**
|
||||
|
||||
| 场景 | 基础间隔 | 抖动 |
|
||||
| --- | --- | --- |
|
||||
| 普通网页 | 800 ~ 1500 ms | 100 ~ 300 ms |
|
||||
| 有反爬检测的站点 | 1500 ~ 3000 ms | 400 ~ 800 ms |
|
||||
| 调试阶段 | 任意 | `0`(便于复现问题) |
|
||||
|
||||
### 步骤定位模式
|
||||
|
||||
| 模式 | 徽标样式 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 选择器定位 | 蓝色圆形 | 使用自动生成的唯一 CSS 选择器,元素移动/滚动也不丢失 |
|
||||
| 坐标定位 | 橙色方块 | 使用屏幕比例坐标,适用于选择器无法唯一生成的元素 |
|
||||
|
||||
选择器未命中时会**自动回退到坐标点击**并记入日志;徽标变灰表示当前页面未匹配到该选择器。
|
||||
|
||||
---
|
||||
|
||||
## 配置导入导出
|
||||
|
||||
- **导出**:点击「导出」,配置 JSON 自动复制到剪贴板(失败时在面板内弹出可手动复制的文本框)
|
||||
- **导入**:点击「导入」,粘贴 JSON 文本
|
||||
|
||||
配置以域名为维度保存,存储键为 `zkcoi_vac_cfg_v1_<域名>`。旧版本(v1.0.1 及之前)的配置会在首次加载时**自动迁移**,不会丢失。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 点击「拾取位置」后没反应?
|
||||
|
||||
按以下顺序排查:
|
||||
|
||||
1. 面板日志是否出现「v1.0.2 已就绪」——没有说明脚本未运行,检查 `@match` 是否覆盖该站点,或页面是否处于 iframe 中
|
||||
2. 点「拾取」后鼠标是否变为十字光标——没有说明按钮事件被页面拦截,改用脚本菜单里的「拾取点击位置」入口
|
||||
3. 前两步正常但点击元素无反应——查看面板日志中的红色报错,用「📋 复制日志」发给我
|
||||
|
||||
### 日志无法选中复制?
|
||||
|
||||
v1.0.2 起日志区已支持直接鼠标框选,也可点击 **「📋 复制日志」** 一键复制全部(自动附时间戳与页面 URL)。
|
||||
|
||||
### 目标按钮点了没反应?
|
||||
|
||||
JS 派发的点击是"不可信事件"(`isTrusted = false`),少数场景可能不响应:
|
||||
|
||||
- 校验 `isTrusted` 的组件
|
||||
- 复杂拖拽或手势验证码
|
||||
- 位于 iframe 内的元素(脚本已声明 `@noframes`,无法直接操作内部 DOM)
|
||||
|
||||
**解决办法**:把「按住时长」调大(如 `80 ~ 150 ms`),或把该步骤切换为坐标模式(徽标菜单 →「改为坐标」)。
|
||||
|
||||
### 运行时报错 `Failed to convert value to 'Window'`
|
||||
|
||||
已在 v1.0.2 修复。原因是 Tampermonkey 沙箱中的 `window` 是代理对象,不能直接作为 `MouseEvent` 的 `view`。现在改用 `document.defaultView`,并提供五级降级构造链。
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.2
|
||||
|
||||
- 修复 Tampermonkey 沙箱下 `MouseEvent` 构造失败导致点击完全不生效的问题(五级降级链)
|
||||
- 日志区支持鼠标框选,新增「📋 复制日志」「清空日志」按钮,复制三级回退
|
||||
- 错误日志自动给日志框描红,并提示可复制反馈
|
||||
- 日志上限由 60 行提高到 100 行
|
||||
- 标识替换为官网与作者信息;存储键迁移至 `zkcoi_` 前缀(自动迁移旧配置)
|
||||
|
||||
### v1.0.1
|
||||
|
||||
- 修复 `CSS.escape` 被局部变量遮蔽导致拾取后静默失败(脚本无法添加步骤)的核心 bug
|
||||
- 拾取模式增加十字光标、按钮状态切换等明确反馈
|
||||
- 拾取/运行期间的异常直接写入面板日志
|
||||
- 点击 iframe 时给出明确提示
|
||||
|
||||
### v1.0.0
|
||||
|
||||
- 首个版本:可视化拾取、多点串行点击、逐步骤间隔、循环、抖动、配置持久化
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── 可视化自动点击器.user.js # 用户脚本本体,安装这一个文件即可
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 技术说明
|
||||
|
||||
- 纯原生 JavaScript,无任何外部依赖
|
||||
- 面板使用 **Shadow DOM** 渲染,样式与页面完全隔离,不污染也不受页面样式影响
|
||||
- 事件派发序列:`pointerover → mouseover → pointermove → mousemove → pointerdown → mousedown → (hold) → pointerup → mouseup → click`
|
||||
- 选择器生成策略:优先 `id`,其次 `tag + class`,再退化到 `nth-of-type`,并对每一层做唯一性校验
|
||||
|
||||
## 许可
|
||||
|
||||
[MIT License](./LICENSE) © 2026 zkcoi
|
||||
|
||||
## 免责声明
|
||||
|
||||
本脚本仅用于提升个人在自有或已授权页面上的操作效率。请遵守目标网站的服务条款与相关法律法规,勿用于刷量、薅羊毛、恶意攻击等违反规则的用途。因使用本脚本造成的任何后果由使用者自行承担。
|
||||
|
||||
---
|
||||
|
||||
**作者**:zkcoi | **官网**:<https://www.zkcoi.com>
|
||||
+857
@@ -0,0 +1,857 @@
|
||||
// ==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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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) {}
|
||||
})();
|
||||
Reference in new issue
Block a user