// ==UserScript== // @name 高级页面查找 // @namespace local.advanced.find.pro.v3.fixed // @version 3.1.0 // @description 替代 Ctrl+F,支持布尔表达式、括号、结果侧栏、CSS 范围、搜索历史、同域 iframe 搜索;增量扫描 DOM、大页面提速、iframe 缓存、可折叠高级选项、拖拽面板与位置记忆。修复初始化失败、页面乱跳、iframe 变动失效、语法损坏等问题。 // @match *://*/* // @grant GM_registerMenuCommand // @run-at document-start // @license AI写的,随便用随便改 // ==/UserScript== (function () { 'use strict'; /********************************************************* * 常量 *********************************************************/ const STORAGE_KEY = '__tm_adv_find_v3_1_0_config__'; const HISTORY_KEY = '__tm_adv_find_v3_1_0_history__'; const PANEL_ID = 'tm-adv-find-v3-panel'; const STYLE_ID = 'tm-adv-find-v3-style'; const MARK_CLASS = 'tm-adv-find-v3-mark'; const CURRENT_CLASS = 'tm-adv-find-v3-current'; const RESULT_ITEM_CLASS = 'tm-adv-find-v3-result-item'; const MAX_HISTORY = 20; const CONTAINER_UID_PROP = '__tm_adv_find_v310_uid__'; const DEFAULT_COLORS = [ '#fff59d', '#ffccbc', '#b3e5fc', '#c8e6c9', '#e1bee7', '#f8bbd0', '#dcedc8', '#ffe082', '#b2dfdb', '#d1c4e9', '#ffecb3', '#c5cae9' ]; const BASE_EXCLUDE_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'BUTTON' ]); const BLOCKISH_SELECTOR = [ 'p', 'li', 'td', 'th', 'dt', 'dd', 'pre', 'code', 'blockquote', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav', 'div', 'span', 'label', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ].join(','); /********************************************************* * 状态 *********************************************************/ const state = { panel: null, inputQuery: null, inputScope: null, resultInfo: null, colorsWrap: null, historyWrap: null, sidebarWrap: null, advancedWrap: null, advancedToggle: null, progressInfo: null, chkHighlight: null, chkCaseSensitive: null, chkWholeWord: null, chkAutoRefresh: null, chkVisibleOnly: null, chkIncludeCode: null, btnSearch: null, btnPrev: null, btnNext: null, btnClear: null, btnClose: null, query: '', scopeSelector: '', panelVisible: false, currentIndex: -1, currentVirtualParent: null, matches: [], observers: [], frameLoadHandlers: [], docsSnapshot: [], isInternalUpdating: false, currentParsed: null, searchJobId: 0, uidSeed: 1, visibleCache: new WeakMap(), dirtyContainers: new Map(), frameCache: { dirty: true, stamp: '', docs: [] }, options: { highlight: true, caseSensitive: false, wholeWord: false, autoRefresh: true, visibleOnly: true, includeCode: false, termColorMap: {}, scopeSelector: '', advancedCollapsed: false, panelPos: { top: 16, left: null } }, containerRecords: new Map() }; /********************************************************* * 存储 *********************************************************/ function loadConfig() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const cfg = JSON.parse(raw); if (!cfg || typeof cfg !== 'object') return; Object.assign(state.options, cfg); if (!state.options.termColorMap || typeof state.options.termColorMap !== 'object') { state.options.termColorMap = {}; } if (!state.options.panelPos || typeof state.options.panelPos !== 'object') { state.options.panelPos = { top: 16, left: null }; } } catch (e) {} } function saveConfig() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.options)); } catch (e) {} } function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); const arr = JSON.parse(raw || '[]'); return Array.isArray(arr) ? arr : []; } catch (e) { return []; } } function saveHistory(query, scopeSelector) { const q = String(query || '').trim(); const s = String(scopeSelector || '').trim(); if (!q) return; const key = JSON.stringify({ q, s }); let arr = loadHistory().filter(x => JSON.stringify(x) !== key); arr.unshift({ q, s }); arr = arr.slice(0, MAX_HISTORY); try { localStorage.setItem(HISTORY_KEY, JSON.stringify(arr)); } catch (e) {} } /********************************************************* * 工具 *********************************************************/ function debounce(fn, delay = 300) { let t = null; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), delay); }; } function runInternalUpdate(fn) { state.isInternalUpdating = true; try { fn(); } finally { setTimeout(() => { state.isInternalUpdating = false; }, 0); } } function escapeRegExp(str) { return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, s => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s])); } function unique(arr) { return Array.from(new Set(arr)); } function normalizeHexColor(color) { try { if (/^#[0-9a-fA-F]{6}$/.test(color)) return color; if (!document.body) return '#fff59d'; const el = document.createElement('div'); el.style.color = color; document.body.appendChild(el); const rgb = getComputedStyle(el).color; el.remove(); const m = rgb.match(/\d+/g); if (!m || m.length < 3) return '#fff59d'; const [r, g, b] = m.map(Number); return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); } catch (e) { return '#fff59d'; } } function getDefaultColor(i) { return DEFAULT_COLORS[i % DEFAULT_COLORS.length]; } function makeSnippet(text, start, end, radius = 30) { const s = Math.max(0, start - radius); const e = Math.min(text.length, end + radius); let snippet = text.slice(s, e).replace(/\s+/g, ' ').trim(); if (s > 0) snippet = '…' + snippet; if (e < text.length) snippet += '…'; return snippet; } function yieldToUI() { return new Promise(resolve => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => resolve(), { timeout: 30 }); } else { setTimeout(resolve, 0); } }); } function getElementUid(el) { if (!el) return ''; if (!el[CONTAINER_UID_PROP]) { Object.defineProperty(el, CONTAINER_UID_PROP, { value: 'c' + (state.uidSeed++), enumerable: false, configurable: true }); } return el[CONTAINER_UID_PROP]; } function getExcludeTags() { const set = new Set(BASE_EXCLUDE_TAGS); if (!state.options.includeCode) { set.add('CODE'); set.add('PRE'); } return set; } function updateProgress(text) { if (state.progressInfo) state.progressInfo.textContent = text || ''; } function updateResultInfo() { const total = state.matches.length; const curr = total ? state.currentIndex + 1 : 0; if (state.resultInfo) state.resultInfo.textContent = `${curr} / ${total}`; } function ensureTermColors(terms) { terms.forEach((t, i) => { if (!state.options.termColorMap[t]) { state.options.termColorMap[t] = getDefaultColor(i); } }); saveConfig(); } function isSameOriginFrame(frame) { try { return !!frame.contentDocument; } catch (e) { return false; } } function getFramePath(doc) { if (doc === document) return '主文档'; try { const allDocs = collectSearchDocumentsCached(); const idx = allDocs.indexOf(doc); return idx >= 0 ? `iframe #${idx}` : 'iframe'; } catch (e) { return 'iframe'; } } function getSearchRootsForDoc(doc, selector) { if (!doc || !doc.body) return []; if (!selector || !selector.trim()) return [doc.body]; try { const list = Array.from(doc.querySelectorAll(selector)); return list.length ? list : []; } catch (e) { return []; } } function isAsciiWord(str) { return /^[A-Za-z0-9_]+$/.test(str); } function hasCJK(str) { return /[\u3400-\u9FFF]/.test(str); } function isBoundaryCharForTerm(term, ch) { if (!ch) return false; if (hasCJK(term)) return /[\u3400-\u9FFF]/.test(ch); if (isAsciiWord(term)) return /[A-Za-z0-9_]/.test(ch); return /[A-Za-z0-9_\u3400-\u9FFF]/.test(ch); } function isWholeWordMatch(text, start, end, term) { const prev = start > 0 ? text[start - 1] : ''; const next = end < text.length ? text[end] : ''; const prevBlock = isBoundaryCharForTerm(term, prev); const nextBlock = isBoundaryCharForTerm(term, next); return !prevBlock && !nextBlock; } function isElementInViewport(el, margin = 40) { try { if (!el || !el.isConnected) return false; const rect = el.getBoundingClientRect(); const vw = el.ownerDocument.defaultView || window; const h = vw.innerHeight || 0; const w = vw.innerWidth || 0; return ( rect.bottom >= margin && rect.right >= 0 && rect.top <= h - margin && rect.left <= w ); } catch (e) { return false; } } function scrollElementIntoViewIfNeeded(el, opts = {}) { try { if (!el || !el.isConnected) return; if (isElementInViewport(el, 50)) return; el.scrollIntoView({ behavior: opts.behavior || 'smooth', block: opts.block || 'center', inline: opts.inline || 'nearest' }); } catch (e) {} } function safeFocus(el) { if (!el) return; try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} } } function whenDocumentReady(fn) { if (document.body && document.head) { fn(); return; } const onReady = () => { if (document.body && document.head) { document.removeEventListener('DOMContentLoaded', onReady, true); fn(); } }; document.addEventListener('DOMContentLoaded', onReady, true); const timer = setInterval(() => { if (document.body && document.head) { clearInterval(timer); document.removeEventListener('DOMContentLoaded', onReady, true); fn(); } }, 30); } /********************************************************* * 样式 *********************************************************/ function injectStyleToDoc(doc) { if (!doc || !doc.head) return; if (doc.getElementById(STYLE_ID)) return; const style = doc.createElement('style'); style.id = STYLE_ID; style.textContent = ` .${MARK_CLASS} { transition: box-shadow .15s ease, background-color .15s ease; border-radius: 2px; padding: 0 1px; } .${CURRENT_CLASS} { outline: 2px solid #ff3b30 !important; box-shadow: 0 0 0 3px rgba(255,59,48,.18) !important; } #${PANEL_ID} { position: fixed; top: 16px; right: 16px; width: 520px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px); overflow: hidden; background: #fff; border: 1px solid #d0d7de; border-radius: 12px; box-shadow: 0 12px 36px rgba(0,0,0,.18); z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #222; user-select: none; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} input, #${PANEL_ID} textarea { user-select: text; } #${PANEL_ID} .tm-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid #eee; font-size:14px; font-weight:600; gap:10px; cursor: move; background: linear-gradient(to bottom, #fff, #fafafa); } #${PANEL_ID} .tm-title { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #${PANEL_ID} .tm-head-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; } #${PANEL_ID} .tm-body { padding:10px 12px 12px; max-height: calc(100vh - 80px); overflow:auto; } #${PANEL_ID} .tm-row { margin-top:8px; } #${PANEL_ID} .tm-row:first-child { margin-top:0; } #${PANEL_ID} .tm-label { font-size:12px; color:#555; margin-bottom:5px; } #${PANEL_ID} input[type="text"] { width:100%; padding:9px 10px; border:1px solid #cfd8dc; border-radius:8px; outline:none; font-size:13px; } #${PANEL_ID} input[type="text"]:focus { border-color:#409eff; box-shadow:0 0 0 3px rgba(64,158,255,.12); } #${PANEL_ID} .tm-grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px 8px; margin-top:10px; } #${PANEL_ID} .tm-opt { display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer; user-select:none; } #${PANEL_ID} .tm-actions { display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; align-items:center; } #${PANEL_ID} button { border:1px solid #cfd8dc; background:#f8f9fa; color:#222; border-radius:8px; padding:6px 10px; cursor:pointer; font-size:12px; } #${PANEL_ID} button:hover { background:#eef3f8; } #${PANEL_ID} .tm-btn-primary { background:#409eff; color:#fff; border-color:#409eff; } #${PANEL_ID} .tm-btn-primary:hover { background:#2f8ef0; } #${PANEL_ID} .tm-result { margin-left:auto; font-size:12px; color:#444; } #${PANEL_ID} .tm-progress { font-size:11px; color:#777; margin-left:6px; } #${PANEL_ID} .tm-box { margin-top:10px; border:1px solid #f0f0f0; border-radius:8px; padding:8px; background:#fcfcfc; } #${PANEL_ID} .tm-box-title { font-size:12px; color:#444; font-weight:600; margin-bottom:6px; } #${PANEL_ID} .tm-colors { max-height:120px; overflow:auto; } #${PANEL_ID} .tm-history { max-height:100px; overflow:auto; } #${PANEL_ID} .tm-sidebar { max-height:240px; overflow:auto; } #${PANEL_ID} .tm-help { max-height:180px; overflow:auto; font-size:12px; line-height:1.55; color:#555; white-space:normal; } #${PANEL_ID} .tm-color-row { display:flex; align-items:center; gap:8px; margin:4px 0; } #${PANEL_ID} .tm-color-label { flex:1; min-width:0; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #${PANEL_ID} .tm-history-item { padding:5px 6px; border-radius:6px; cursor:pointer; font-size:12px; margin:3px 0; border:1px solid transparent; } #${PANEL_ID} .tm-history-item:hover { background:#f1f6fb; border-color:#d8e7f8; } #${PANEL_ID} .${RESULT_ITEM_CLASS} { padding:6px 8px; border-radius:8px; cursor:pointer; border:1px solid #edf0f2; margin:5px 0; font-size:12px; line-height:1.45; background:#fff; } #${PANEL_ID} .${RESULT_ITEM_CLASS}:hover { background:#f7fbff; border-color:#cfe4ff; } #${PANEL_ID} .${RESULT_ITEM_CLASS}.active { background:#eef6ff; border-color:#8ab8ff; } #${PANEL_ID} .tm-muted { color:#666; font-size:12px; } #${PANEL_ID} .tm-mini { color:#888; font-size:11px; margin-top:2px; } #${PANEL_ID} .tm-snippet { color:#222; word-break:break-word; } #${PANEL_ID} .tm-result-meta { color:#777; font-size:11px; margin-top:3px; } #${PANEL_ID} .tm-advanced-toggle { width:100%; display:flex; align-items:center; justify-content:space-between; padding:8px 10px; background:#f7f9fb; border:1px solid #e8edf2; border-radius:8px; margin-top:10px; font-size:12px; } #${PANEL_ID} .tm-advanced-wrap.collapsed { display:none; } #${PANEL_ID} .tm-close-btn { min-width:34px; } .tm-dragging, .tm-dragging * { cursor: move !important; } `; doc.head.appendChild(style); } /********************************************************* * iframe 缓存 *********************************************************/ function getFrameCacheStamp(rootDoc = document) { const parts = []; const seen = new Set(); function walk(doc, path) { if (!doc || seen.has(doc)) return; seen.add(doc); let frames = []; try { frames = Array.from(doc.querySelectorAll('iframe, frame')); } catch (e) {} parts.push(`${path}:${frames.length}`); frames.forEach((frame, i) => { let same = 0; let subDoc = null; try { if (frame.contentDocument) { same = 1; subDoc = frame.contentDocument; } } catch (e) {} parts.push(`${path}.${i}:${same}:${frame.src || ''}`); if (same && subDoc) walk(subDoc, `${path}.${i}`); }); } walk(rootDoc, 'r'); return parts.join('|'); } function collectSearchDocumentsCached(rootDoc = document) { const stamp = getFrameCacheStamp(rootDoc); if (!state.frameCache.dirty && state.frameCache.stamp === stamp && state.frameCache.docs.length) { return state.frameCache.docs.slice(); } const out = []; const seen = new Set(); function walk(doc) { try { if (!doc || seen.has(doc)) return; seen.add(doc); out.push(doc); const frames = doc.querySelectorAll('iframe, frame'); for (const frame of frames) { if (!isSameOriginFrame(frame)) continue; const subDoc = frame.contentDocument; if (subDoc) walk(subDoc); } } catch (e) {} } walk(rootDoc); state.frameCache.docs = out; state.frameCache.stamp = stamp; state.frameCache.dirty = false; return out.slice(); } function markFrameCacheDirty() { state.frameCache.dirty = true; } /********************************************************* * Tokenizer / Parser *********************************************************/ function tokenizeQuery(query) { const tokens = []; const re = /kw:"[^"]*"|"[^"]*"|\(|\)|\bAND\b|\bOR\b|\bNOT\b|[^\s()]+/gi; let m; while ((m = re.exec(query)) !== null) { const tk = m[0].trim(); if (!tk) continue; // 跳过空引号(如 "" 或 kw:""),避免产生空关键词污染解析与颜色配置 if (/^(?:kw:)?""$/.test(tk)) continue; tokens.push(tk); } return tokens; } function isOperator(token) { return /^(AND|OR|NOT)$/i.test(token); } function precedence(op) { const u = op.toUpperCase(); if (u === 'NOT') return 3; if (u === 'AND') return 2; if (u === 'OR') return 1; return 0; } function stripOuterQuotes(s) { if (/^".*"$/.test(s)) return s.slice(1, -1); return s; } function buildTypeRegex(typeName, options) { const map = { num: /-?\d+(?:\.\d+)?/g, int: /-?\d+\b/g, float: /-?\d+\.\d+\b/g, en: /[A-Za-z]+/g, enword: /\b[A-Za-z]+\b/g, zh: /[\u4e00-\u9fff]+/g, upper: /\b[A-Z]+\b/g, lower: /\b[a-z]+\b/g, email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, url: /\bhttps?:\/\/[^\s<>"']+[^\s<>"'.,;:!?,。;:!?]/g, date: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g, percent: /-?\d+(?:\.\d+)?%/g, money: /(?:¥|¥|\$|€|£)\s?-?\d+(?:,\d{3})*(?:\.\d+)?|\b-?\d+(?:,\d{3})*(?:\.\d+)?\s?(?:元|美元|usd|cny|rmb|eur)\b/gi }; const src = map[typeName]; if (!src) return null; let flags = src.flags; if (!options.caseSensitive && !flags.includes('i')) flags += 'i'; if (options.caseSensitive) flags = flags.replace(/i/g, ''); flags = unique(flags.split('')).join(''); return new RegExp(src.source, flags); } function buildNumberConditionMatcher(expr) { expr = expr.trim(); const range = expr.match(/^(-?\d+(?:\.\d+)?)\.\.(-?\d+(?:\.\d+)?)$/); if (range) { const a = parseFloat(range[1]); const b = parseFloat(range[2]); const min = Math.min(a, b); const max = Math.max(a, b); return function (text) { const out = []; const re = /-?\d+(?:\.\d+)?/g; let m; while ((m = re.exec(text)) !== null) { const v = parseFloat(m[0]); if (v >= min && v <= max) { out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } } return out; }; } const cmp = expr.match(/^(>=|<=|>|<|=)(-?\d+(?:\.\d+)?)$/); if (cmp) { const op = cmp[1]; const n = parseFloat(cmp[2]); return function (text) { const out = []; const re = /-?\d+(?:\.\d+)?/g; let m; while ((m = re.exec(text)) !== null) { const v = parseFloat(m[0]); let ok = false; if (op === '>') ok = v > n; if (op === '>=') ok = v >= n; if (op === '<') ok = v < n; if (op === '<=') ok = v <= n; if (op === '=') ok = v === n; if (ok) out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } return out; }; } return null; } function getSubstringMatches(text, keyword, options) { const out = []; if (!keyword) return out; const sourceText = options.caseSensitive ? text : text.toLowerCase(); const sourceKey = options.caseSensitive ? keyword : keyword.toLowerCase(); let from = 0; while (from <= sourceText.length) { const idx = sourceText.indexOf(sourceKey, from); if (idx < 0) break; const matchLen = sourceKey.length; const end = idx + matchLen; if (!options.wholeWord || isWholeWordMatch(text, idx, end, keyword)) { out.push({ start: idx, end, text: text.slice(idx, end) }); } from = idx + Math.max(1, matchLen); } return out; } function buildKeywordMatcher(keyword, options) { return { test(text) { return getSubstringMatches(text, keyword, options).length > 0; }, getMatches(text) { return getSubstringMatches(text, keyword, options); } }; } function makeRegexGetter(regex) { return function (text) { const out = []; regex.lastIndex = 0; let m; while ((m = regex.exec(text)) !== null) { if (!m[0]) { regex.lastIndex++; continue; } out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } return out; }; } function makeRegexTester(regex) { return function (text) { regex.lastIndex = 0; return regex.test(text); }; } function buildTerm(token, options) { if (!token) return null; if (/^kw:/i.test(token)) { const body = stripOuterQuotes(token.replace(/^kw:/i, '')); const matcher = buildKeywordMatcher(body, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } if (/^num:/i.test(token)) { const body = token.replace(/^num:/i, ''); const fn = buildNumberConditionMatcher(body); if (!fn) throw new Error(`非法数字条件:${body}`); return { raw: token, colorKey: token, test(text) { return fn(text).length > 0; }, getMatches: fn }; } if (/^type:/i.test(token)) { const body = token.replace(/^type:/i, '').toLowerCase(); const regex = buildTypeRegex(body, options); if (!regex) throw new Error(`未知类型:${body}`); return { raw: token, colorKey: token, test: makeRegexTester(regex), getMatches: makeRegexGetter(regex) }; } if (/^re:/i.test(token)) { const body = token.replace(/^re:/i, ''); try { const regex = new RegExp(body, options.caseSensitive ? 'g' : 'gi'); return { raw: token, colorKey: token, test: makeRegexTester(regex), getMatches: makeRegexGetter(regex) }; } catch (e) { throw new Error(`非法正则:${body} (${e.message || '编译失败'})`); } } if (/^(>=|<=|>|<|=)-?\d+(?:\.\d+)?$/.test(token) || /^-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)?$/.test(token)) { const fn = buildNumberConditionMatcher(token); if (!fn) throw new Error(`非法数字条件:${token}`); return { raw: token, colorKey: token, test(text) { return fn(text).length > 0; }, getMatches: fn }; } if (/^".*"$/.test(token)) { const body = stripOuterQuotes(token); const matcher = buildKeywordMatcher(body, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } const knownType = buildTypeRegex(token.toLowerCase(), options); if (knownType) { return { raw: token, colorKey: token, test: makeRegexTester(knownType), getMatches: makeRegexGetter(knownType) }; } const matcher = buildKeywordMatcher(token, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } function toRPN(tokens) { const output = []; const ops = []; for (const token of tokens) { if (token === '(') { ops.push(token); } else if (token === ')') { while (ops.length && ops[ops.length - 1] !== '(') { output.push(ops.pop()); } if (!ops.length) throw new Error('括号不匹配'); ops.pop(); } else if (isOperator(token)) { const op = token.toUpperCase(); while ( ops.length && isOperator(ops[ops.length - 1]) && ( (op !== 'NOT' && precedence(ops[ops.length - 1]) >= precedence(op)) || (op === 'NOT' && precedence(ops[ops.length - 1]) > precedence(op)) ) ) { output.push(ops.pop()); } ops.push(op); } else { output.push(token); } } while (ops.length) { const op = ops.pop(); if (op === '(' || op === ')') throw new Error('括号不匹配'); output.push(op); } return output; } function buildASTFromRPN(rpn, options) { const stack = []; const termsForColor = []; for (const token of rpn) { if (isOperator(token)) { const op = token.toUpperCase(); if (op === 'NOT') { const a = stack.pop(); if (!a) throw new Error('NOT 缺少操作数'); stack.push({ type: 'NOT', child: a }); } else { const b = stack.pop(); const a = stack.pop(); if (!a || !b) throw new Error(`${op} 缺少操作数`); stack.push({ type: op, left: a, right: b }); } } else { const term = buildTerm(token, options); stack.push({ type: 'TERM', term }); termsForColor.push(term.colorKey); } } if (stack.length !== 1) throw new Error('表达式不完整'); return { ast: stack[0], termsForColor }; } function parseBooleanQuery(query, options) { const tokens = tokenizeQuery(query); if (!tokens.length) return { ast: null, termsForColor: [] }; const rpn = toRPN(tokens); return buildASTFromRPN(rpn, options); } function evaluateAST(ast, text) { if (!ast) return false; switch (ast.type) { case 'TERM': return ast.term.test(text); case 'NOT': return !evaluateAST(ast.child, text); case 'AND': return evaluateAST(ast.left, text) && evaluateAST(ast.right, text); case 'OR': return evaluateAST(ast.left, text) || evaluateAST(ast.right, text); default: return false; } } function collectPositiveTerms(ast, negated = false, out = []) { if (!ast) return out; if (ast.type === 'TERM') { if (!negated) out.push(ast.term); return out; } if (ast.type === 'NOT') { collectPositiveTerms(ast.child, !negated, out); return out; } if (ast.type === 'AND' || ast.type === 'OR') { collectPositiveTerms(ast.left, negated, out); collectPositiveTerms(ast.right, negated, out); } return out; } function uniqueTermsByKey(arr) { const map = new Map(); for (const t of arr) { if (!map.has(t.colorKey)) map.set(t.colorKey, t); } return Array.from(map.values()); } /********************************************************* * 可见性 / 文本节点 *********************************************************/ function isElementActuallyVisible(el) { if (!el || !el.isConnected) return false; if (state.visibleCache.has(el)) { return state.visibleCache.get(el); } const doc = el.ownerDocument; const win = doc.defaultView; if (!win) { state.visibleCache.set(el, false); return false; } let cur = el; while (cur && cur.nodeType === 1) { if (cur.hidden) { state.visibleCache.set(el, false); return false; } if (cur.getAttribute && cur.getAttribute('aria-hidden') === 'true') { state.visibleCache.set(el, false); return false; } const style = win.getComputedStyle(cur); if ( style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0' ) { state.visibleCache.set(el, false); return false; } cur = cur.parentElement; } const ok = el.getClientRects().length > 0; state.visibleCache.set(el, ok); return ok; } function isSearchableTextNode(node) { if (!node || !node.parentElement) return false; const parent = node.parentElement; const excludeTags = getExcludeTags(); if (excludeTags.has(parent.tagName)) return false; if (parent.closest(`#${PANEL_ID}`)) return false; if (parent.closest(`.${MARK_CLASS}`)) return false; if (parent.closest('[contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]')) return false; if (!node.nodeValue || !node.nodeValue.trim()) return false; if (state.options.visibleOnly && !isElementActuallyVisible(parent)) return false; return true; } function collectTextNodes(root) { const doc = root.ownerDocument || root; let walker; try { walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return isSearchableTextNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); } catch (e) { return []; } const nodes = []; let n; while ((n = walker.nextNode())) nodes.push(n); return nodes; } function mergeMatches(matches) { if (!matches.length) return []; matches.sort((a, b) => { if (a.start !== b.start) return a.start - b.start; return b.end - a.end; }); const result = []; let lastEnd = -1; for (const m of matches) { if (m.start >= lastEnd) { result.push(m); lastEnd = m.end; } } return result; } function getMatchesForNodeText(text, ast, positiveTerms) { if (!evaluateAST(ast, text)) return []; const out = []; for (const term of positiveTerms) { const color = state.options.termColorMap[term.colorKey] || '#fff59d'; const arr = term.getMatches(text) || []; for (const m of arr) { out.push({ start: m.start, end: m.end, text: m.text, token: term.colorKey, color }); } } return mergeMatches(out); } /********************************************************* * 记录管理 *********************************************************/ function getRecordKey(el) { return `${getFramePath(el.ownerDocument)}::${getElementUid(el)}`; } function getOrCreateRecord(el, docLabel) { const key = getRecordKey(el); let record = state.containerRecords.get(key); if (!record) { record = { key, el, doc: el.ownerDocument, docLabel: docLabel || getFramePath(el.ownerDocument), marks: [], matches: [] }; state.containerRecords.set(key, record); } return record; } function clearRecord(record) { if (!record) return; const touchedParents = new Set(); for (const mark of record.marks) { try { if (!mark || !mark.isConnected) continue; const parent = mark.parentNode; if (!parent) continue; touchedParents.add(parent); parent.replaceChild(mark.ownerDocument.createTextNode(mark.textContent || ''), mark); } catch (e) {} } touchedParents.forEach(p => { try { p.normalize(); } catch (e) {} }); record.marks = []; record.matches = []; } function clearAllRecords() { for (const record of state.containerRecords.values()) { clearRecord(record); } state.containerRecords.clear(); state.matches = []; state.currentIndex = -1; if (state.currentVirtualParent && state.currentVirtualParent.isConnected) { state.currentVirtualParent.classList.remove(CURRENT_CLASS); } state.currentVirtualParent = null; renderResultsSidebar(); updateResultInfo(); } function removeRecordsInSubtree(container) { const keysToDelete = []; for (const [key, record] of state.containerRecords.entries()) { const el = record.el; if (!el) { keysToDelete.push(key); continue; } if (!el.isConnected) { clearRecord(record); keysToDelete.push(key); continue; } if (container === el || container.contains(el)) { clearRecord(record); keysToDelete.push(key); } } keysToDelete.forEach(k => state.containerRecords.delete(k)); } function cleanupDisconnectedRecords() { const keysToDelete = []; for (const [key, record] of state.containerRecords.entries()) { if (!record.el || !record.el.isConnected) { clearRecord(record); keysToDelete.push(key); } } keysToDelete.forEach(k => state.containerRecords.delete(k)); } function compareRecords(a, b) { if (a.doc !== b.doc) { const docs = state.docsSnapshot; const ai = docs.indexOf(a.doc); const bi = docs.indexOf(b.doc); return ai - bi; } if (a.el === b.el) return 0; if (!a.el || !b.el) return 0; const pos = a.el.compareDocumentPosition(b.el); if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; } function rebuildGlobalMatches() { cleanupDisconnectedRecords(); const orderedRecords = Array.from(state.containerRecords.values()).sort(compareRecords); state.matches = []; for (const record of orderedRecords) { if (record.matches && record.matches.length) { state.matches.push(...record.matches); } } if (!state.matches.length) { state.currentIndex = -1; } else if (state.currentIndex >= state.matches.length) { state.currentIndex = state.matches.length - 1; } } /********************************************************* * 高亮与结果 *********************************************************/ function pushVirtualOnlyResult(parentEl, node, text, docLabel) { const record = getOrCreateRecord(parentEl, docLabel); record.matches.push({ el: null, node, parentEl, doc: node.ownerDocument, docLabel, snippet: makeSnippet(text, 0, Math.min(text.length, 1)), term: 'NOT-only' }); } function highlightTextNode(node, ast, positiveTerms, docLabel) { const text = node.nodeValue; if (!text || !text.trim() || !node.parentElement || !node.parentNode) return; const parentEl = node.parentElement; const record = getOrCreateRecord(parentEl, docLabel); if (!positiveTerms.length) { if (evaluateAST(ast, text)) { pushVirtualOnlyResult(parentEl, node, text, docLabel); } return; } const matches = getMatchesForNodeText(text, ast, positiveTerms); if (!matches.length) return; const frag = node.ownerDocument.createDocumentFragment(); let last = 0; for (const m of matches) { if (m.start > last) { frag.appendChild(node.ownerDocument.createTextNode(text.slice(last, m.start))); } const mark = node.ownerDocument.createElement('mark'); mark.className = MARK_CLASS; mark.dataset.term = m.token; mark.textContent = text.slice(m.start, m.end); if (state.options.highlight) { mark.style.backgroundColor = m.color; mark.style.color = '#000'; mark.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,.08)'; } else { mark.style.backgroundColor = 'rgba(0,0,0,.03)'; mark.style.color = 'inherit'; mark.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,.22)'; } frag.appendChild(mark); record.marks.push(mark); record.matches.push({ el: mark, node: null, parentEl, doc: node.ownerDocument, docLabel, snippet: makeSnippet(text, m.start, m.end), term: m.token }); last = m.end; } if (last < text.length) { frag.appendChild(node.ownerDocument.createTextNode(text.slice(last))); } try { node.parentNode.replaceChild(frag, node); } catch (e) {} } // scroll=false 时仅添加高亮 class,不滚动、不抢文本选区焦点 function revealVirtualTextNode(match, scroll = true) { if (!match || !match.node || !match.node.parentElement) return; const parent = match.node.parentElement; if (scroll) { try { scrollElementIntoViewIfNeeded(parent, { behavior: 'smooth', block: 'center', inline: 'nearest' }); } catch (e) {} try { const sel = match.doc.defaultView.getSelection(); if (sel) { sel.removeAllRanges(); const range = match.doc.createRange(); const text = match.node.nodeValue || ''; const end = Math.min(text.length, 1); range.setStart(match.node, 0); range.setEnd(match.node, end); sel.addRange(range); } } catch (e) {} } try { // 清除上一个虚拟结果遗留在父元素上的高亮,避免多个父节点同时带红色轮廓 if (state.currentVirtualParent && state.currentVirtualParent !== parent && state.currentVirtualParent.isConnected) { state.currentVirtualParent.classList.remove(CURRENT_CLASS); } state.currentVirtualParent = parent; parent.classList.add(CURRENT_CLASS); setTimeout(() => { // 仅当该父节点仍是当前高亮的虚拟父节点时才移除,避免清除后续切换上的高亮 if (parent === state.currentVirtualParent && parent.isConnected) { parent.classList.remove(CURRENT_CLASS); if (state.currentVirtualParent === parent) state.currentVirtualParent = null; } }, 1200); } catch (e) {} } function findFrameElementByDocument(targetDoc, rootDoc = document) { let frames = []; try { frames = rootDoc.querySelectorAll('iframe, frame'); } catch (e) { return null; } for (const f of frames) { try { if (f.contentDocument === targetDoc) return f; if (f.contentDocument) { const nested = findFrameElementByDocument(targetDoc, f.contentDocument); if (nested) return nested; } } catch (e) {} } return null; } function renderResultsSidebar() { const wrap = state.sidebarWrap; if (!wrap) return; wrap.innerHTML = ''; if (!state.matches.length) { wrap.innerHTML = `
error"hello world"kw:error、kw:"hello world">10、>=3.5、<=20、=4210..100 或 num:10..100type:email、type:url、type:date、type:zh、type:enword、type:money、type:percentemail、url、zh、enwordre:\\b[A-Z]{3}\\d{4}\\b(error OR warning) AND NOT success.article、#main、.content p