// ==UserScript== // @name BB脚本-每帧单次点击 // @namespace https://data-baker.com/ // @version 3.0 // @description v3.0 生产版:清理调试日志,逻辑同v2.7(删除isSelected误判跳过、点击后确认+补点兜底、白名单排除未分组)。每帧仅点一次。 // @author WorkBuddy // @match https://cvpc.data-baker.com/app/editor/cv/* // @icon https://cvpc.data-baker.com/favicon.ico // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ===================== 配置区 ===================== const CFG = { targetText: 'face5关键点', // 目标行文字(包含匹配) ignoreGroupText: '未分组', // 要跳过的分组名(黑名单,兜底用) allowedGroupText: '分组', // 白名单:只点分组名包含此关键字的组(如 分组1/分组2) rowClass: 'bb-row-tmpl', // 行容器类名 groupClass: 'bb-row-tmpl--group', // 分组行标记类 trackClass: 'bb-row-tmpl--track', // 轨道行标记类 contentClass: 'bb-row-tmpl__content', selectedClasses: ['is-selected', 'selected', 'active', 'is-active'], // 命中任意一个即视为已选中 clickTarget: 'content', // 点目标行的哪个元素:content / arrow / row clickMode: 'pointer', // pointer 模拟真实鼠标事件;click 用原生 click scrollIntoView: false, // 虚拟列表下滚动会导致节点回收,关闭以防点击打到失效节点 initialDelay: 0, // 切帧后首次尝试延迟(ms) waitTimeout: 4000, // 切帧后最多等待目标渲染的时间(ms) pollInterval: 200 // 帧指示器轮询间隔(ms) }; // ================================================= const s = document.createElement('script'); s.textContent = '(' + pageScript.toString() + ')(' + JSON.stringify(CFG) + ');'; document.documentElement.appendChild(s); s.remove(); function pageScript(cfg) { 'use strict'; let currentFrameId = null; const clickedFrames = new Set(); // 记录本帧是否已处理 let observer = null; let waitTimer = null; function getFrameId() { try { const u = new URL(location.href); return u.searchParams.get('data_id') || u.searchParams.get('frame_id') || u.searchParams.get('frame') || ''; } catch (e) { return ''; } } function isSelected(row) { return cfg.selectedClasses.some(function (c) { return row.classList.contains(c); }); } function scoreRow(row) { let s = 0; if (isSelected(row)) s += 100; if (!row.classList.contains('is-muted')) s += 50; if (!row.classList.contains('is-missing')) s += 50; return s; } function getGroupName(row) { if (row.classList.contains(cfg.groupClass)) { const nameSpan = row.querySelector('.bb-folder-content .text-row-1 span'); return nameSpan ? nameSpan.textContent.trim() : ''; } const allRows = document.querySelectorAll('.' + cfg.rowClass); let foundSelf = false; for (let i = allRows.length - 1; i >= 0; i--) { if (allRows[i] === row) { foundSelf = true; continue; } if (foundSelf && allRows[i].classList.contains(cfg.groupClass)) { const nameSpan = allRows[i].querySelector('.bb-folder-content .text-row-1 span'); return nameSpan ? nameSpan.textContent.trim() : ''; } } return ''; } function findRow() { let best = null, bestScore = -1; const spans = document.querySelectorAll( '.' + cfg.contentClass + ' span,' + '.bb-folder-content span,' + '.text-row-1 span' ); for (let i = 0; i < spans.length; i++) { const text = spans[i].textContent.trim(); if (text.indexOf(cfg.targetText) === -1) continue; const row = spans[i].closest('.' + cfg.rowClass) || spans[i].closest('.' + cfg.contentClass); if (!row) continue; if (!row.classList.contains(cfg.trackClass)) continue; const groupName = getGroupName(row); // 白名单:分组名包含"分组"且不是"未分组"(注意"未分组"也含"分组"二字) const isAllowed = !!groupName && groupName.indexOf(cfg.allowedGroupText) !== -1 && groupName.indexOf(cfg.ignoreGroupText) === -1; // 核心:只认白名单分组,黑名单或未分组一律排除 if (!isAllowed) continue; if (row.classList.contains('is-muted') || row.classList.contains('is-missing')) continue; const sc = scoreRow(row); if (sc > bestScore) { bestScore = sc; best = row; } } return best; } function simulatePointerClick(el) { const rect = el.getBoundingClientRect(); if (!rect.width || !rect.height) return; const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const base = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, detail: 1, button: 0, buttons: 1 }; const ptr = Object.assign({}, base, { pointerType: 'mouse', isPrimary: true }); el.dispatchEvent(new PointerEvent('pointerover', ptr)); el.dispatchEvent(new PointerEvent('pointerenter', ptr)); el.dispatchEvent(new PointerEvent('pointerdown', ptr)); el.dispatchEvent(new MouseEvent('mousedown', base)); el.dispatchEvent(new PointerEvent('pointerup', ptr)); el.dispatchEvent(new MouseEvent('mouseup', base)); el.dispatchEvent(new MouseEvent('click', base)); el.dispatchEvent(new PointerEvent('pointerleave', ptr)); el.dispatchEvent(new PointerEvent('pointerout', ptr)); } function performClick(row) { let el; if (cfg.clickTarget === 'content') { el = row.querySelector('.' + cfg.contentClass) || row; } else if (cfg.clickTarget === 'arrow') { el = row.querySelector('.folder-switch') || row; } else { el = row; } if (cfg.scrollIntoView && el.scrollIntoView) { el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } if (cfg.clickMode === 'pointer') simulatePointerClick(el); else el.click(); } // 核心:当前帧只尝试点击一次(含补点确认) function tryClickOnce() { const frameId = currentFrameId; // 用切帧时已确定的帧标识,不实时读URL避免空值竞态 if (!frameId) return; // 本帧已处理过(已确认选中),直接退出 if (clickedFrames.has(frameId)) return; const row = findRow(); if (!row) return; // 列表未渲染出来,等下次 observer/poll 触发 // 记录本次点击次数(防虚拟列表节点回收导致点空) let attempts = (tryClickOnce._attempts && tryClickOnce._attempts[frameId]) || 0; performClick(row); attempts++; if (!tryClickOnce._attempts) tryClickOnce._attempts = {}; tryClickOnce._attempts[frameId] = attempts; // 延迟确认是否真的选中;虚拟列表回收可能导致本次点击失效,最多补点2次 setTimeout(function () { const r2 = findRow(); if (r2 && isSelected(r2)) { clickedFrames.add(frameId); // 确认生效,本帧完成 stopWaiting(); } else if (attempts < 3) { tryClickOnce(); // 重新找最新的行再点(节点已刷新) } else { clickedFrames.add(frameId); // 已达上限,避免无限重试 stopWaiting(); } }, 120); } function stopWaiting() { if (observer) { observer.disconnect(); observer = null; } clearTimeout(waitTimer); waitTimer = null; } // 切帧后短暂监听 DOM,目标出现并点击后立刻停止 function startWaiting() { stopWaiting(); const frameId = currentFrameId; observer = new MutationObserver(() => { if (clickedFrames.has(frameId)) { stopWaiting(); return; } tryClickOnce(); }); observer.observe(document.body, { childList: true, subtree: true }); waitTimer = setTimeout(stopWaiting, cfg.waitTimeout); // 立即先尝试一次(可能目标已存在) tryClickOnce(); if (clickedFrames.has(frameId)) stopWaiting(); } function onFrameChange(frameId) { if (!frameId) return; if (frameId !== currentFrameId) { currentFrameId = frameId; clickedFrames.delete(frameId); setTimeout(startWaiting, cfg.initialDelay); } } // 检测切帧:1) URL 变化;2) 底部帧指示器 active 变化(最可靠) function getActiveFrameIndex() { const active = document.querySelector('.frame-indicator.active'); if (!active) return ''; const idx = active.querySelector('.frame-indicator__index'); return idx ? idx.textContent.trim() : ''; } let lastFrameIndicator = ''; function checkFrameChange() { const idx = getActiveFrameIndex(); if (!idx) return; if (idx !== lastFrameIndicator) { lastFrameIndicator = idx; onFrameChange('frame-' + idx); } } // 监听 SPA 切帧(URL 变化) const _push = history.pushState, _replace = history.replaceState; history.pushState = function () { _push.apply(this, arguments); onFrameChange(getFrameId()); }; history.replaceState = function () { _replace.apply(this, arguments); onFrameChange(getFrameId()); }; window.addEventListener('popstate', function () { onFrameChange(getFrameId()); }); // 监听帧指示器容器:左右按钮、键盘←→、直接点帧都会让 active 类移动 const indicatorObserver = new MutationObserver(function (mutations) { for (let i = 0; i < mutations.length; i++) { const m = mutations[i]; if (m.type === 'attributes' && m.attributeName === 'class') { checkFrameChange(); return; } if (m.type === 'childList') { checkFrameChange(); return; } } }); const indicatorContainer = document.querySelector('.frame-indicator')?.parentElement?.parentElement; if (indicatorContainer) { indicatorObserver.observe(indicatorContainer, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true }); } // 兜底轮询:高频检测 active frame(应对 observer 没抓到的情况) const framePoll = setInterval(checkFrameChange, cfg.pollInterval); // 初始化 function init() { lastFrameIndicator = getActiveFrameIndex(); const frameId = getFrameId() || ('frame-' + lastFrameIndicator); if (frameId) { currentFrameId = frameId; clickedFrames.delete(frameId); startWaiting(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } } })();