// ==UserScript== // @name 页面关键词查找(与、或、排除) // @namespace local.page-find // @version 1.0.1 // @description Ctrl+Shift+F 搜索页面文字,支持 &、|、|、- 和转义 // @match *://*/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== (() => { 'use strict'; const ALL_NAME = 'tm-page-find-results'; const ACTIVE_NAME = 'tm-page-find-active'; let host = null; let input = null; let counter = null; let matches = []; let current = 0; // 0 表示尚未选中结果 let observer = null; let refreshTimer = null; let highlightStyleInstalled = false; const blockDisplays = new Set([ 'block', 'list-item', 'table-cell', 'table-caption', 'flex', 'grid', 'flow-root', 'inline-block', 'inline-flex', 'inline-grid', 'table' ]); function installHighlightStyle() { if (highlightStyleInstalled) return; const sheet = new CSSStyleSheet(); sheet.replaceSync(` ::highlight(${ALL_NAME}) { background-color: #ffe45b; color: #111; } ::highlight(${ACTIVE_NAME}) { background-color: #ff9632; color: #111; } `); document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; highlightStyleInstalled = true; } function makeElement(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; } /* * 先按未转义的 &、|、| 分隔,再处理词首的 -。 * 优先级:& 高于 |;例如 A & B | C => (A & B) | C。 */ function parseQuery(query) { const tokens = []; let buffer = ''; // 将缓冲区转成关键词,保留是否为排除条件。 function flushWord() { const raw = buffer.trim(); buffer = ''; if (!raw) return; // - 必须位于词首,并且紧贴后面的关键词。 // \-苹果:普通关键词“-苹果” // - 苹果:普通关键词“- 苹果” const negative = raw.startsWith('-') && raw.length > 1 && !/\s/.test(raw[1]); const value = (negative ? raw.slice(1) : raw) .replace(/\\([&||()\\-])/g, '$1'); tokens.push({ type: 'term', value, negative }); } // 分词:转义过的符号不作为运算符。 for (let i = 0; i < query.length; i++) { const ch = query[i]; if (ch === '\\' && i + 1 < query.length) { buffer += ch + query[++i]; continue; } if ('&||()'.includes(ch)) { flushWord(); tokens.push({ type: ch === '|' ? '|' : ch }); } else { buffer += ch; } } flushWord(); tokens.push({ type: 'end' }); let position = 0; const peek = () => tokens[position]; if (peek().type === 'end') return null; // 或:优先级最低。 function readOr() { let node = readAnd(); while (peek().type === '|') { position++; node = { type: 'or', left: node, right: readAnd() }; } return node; } // 并且:优先于或。 function readAnd() { let node = readPrimary(); while (peek().type === '&') { position++; node = { type: 'and', left: node, right: readPrimary() }; } return node; } // 基础条件:关键词或括号。 function readPrimary() { const token = peek(); if (token.type === 'term') { position++; return token; } if (token.type === '(') { position++; if (peek().type === ')') { throw new Error('括号内不能为空'); } const node = readOr(); if (peek().type !== ')') { throw new Error('缺少右括号 ),或括号内的条件之间缺少 &、|'); } position++; return node; } if (token.type === ')') { throw new Error('右括号 ) 前缺少关键词'); } if (token.type === 'end') { throw new Error('条件不完整:缺少关键词或右括号'); } throw new Error(`运算符 ${token.type} 前缺少关键词`); } const result = readOr(); if (peek().type !== 'end') { if (peek().type === ')') { throw new Error('多余的右括号 )'); } throw new Error( '条件之间需要 & 或 |;不支持 -(...) 整组排除' ); } return result; } function termRegex(term) { const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(escaped, 'gi'); } /* * 将页面文字按最近的块级容器分组。这样同一段里的多个 * inline 元素可以一起搜索,但不同段落不会被错误地拼接。 */ function collectBlocks() { const blocks = []; let block = null; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); function containerOf(node) { let element = node.parentElement; let nearestBlock = null; while (element) { if (/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|SELECT|SVG|CANVAS)$/.test(element.tagName)) { return null; } if (element.hidden || element.inert || element.getAttribute('aria-hidden') === 'true') { return null; } const style = getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') { return null; } if (!nearestBlock && blockDisplays.has(style.display)) { nearestBlock = element; } if (element === document.body) break; element = element.parentElement; } return nearestBlock || document.body; } for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (!node.nodeValue) continue; const container = containerOf(node); if (!container) continue; if (!block || block.container !== container) { block = { container, text: '', segments: [] }; blocks.push(block); } const start = block.text.length; block.text += node.nodeValue; block.segments.push({ node, start, end: block.text.length }); } return blocks.filter(item => item.text.trim()); } function rangeFromOffsets(block, start, end) { const first = block.segments.find(segment => start < segment.end); const last = block.segments.find(segment => end <= segment.end); if (!first || !last) return null; const range = document.createRange(); range.setStart(first.node, start - first.start); range.setEnd(last.node, end - last.start); return range; } function clearHighlights() { CSS.highlights.delete(ALL_NAME); CSS.highlights.delete(ACTIVE_NAME); } function updateCounter() { if (counter) counter.textContent = `${current}/${matches.length}`; } function rebuild(resetCurrent = false) { if (!host) return; if (resetCurrent) current = 0; clearHighlights(); matches = []; // 清除上一次的错误提示。 counter.title = ''; counter.style.color = ''; input.title = ''; input.removeAttribute('aria-invalid'); let expression; try { expression = parseQuery(input.value); } catch (error) { current = 0; counter.textContent = '条件有误'; counter.style.color = '#c33'; counter.title = error.message; input.title = error.message; input.setAttribute('aria-invalid', 'true'); return; } if (!expression) { current = 0; updateCounter(); return; } const regexCache = new Map(); function regexFor(value) { if (!regexCache.has(value)) { regexCache.set(value, termRegex(value)); } return regexCache.get(value); } /* * 返回 null:条件不成立。 * 返回 Set:条件成立,并记录需要高亮的正向关键词。 * 空 Set 也表示成立,例如满足某个排除条件。 */ function evaluate(node, contains) { if (node.type === 'term') { const found = contains(node.value); if (node.negative) { return found ? null : new Set(); } return found ? new Set([node.value]) : null; } // 两个分支都计算,保证所有成立分支的关键词都能高亮。 const left = evaluate(node.left, contains); const right = evaluate(node.right, contains); if (node.type === 'and') { if (left === null || right === null) { return null; } } else { if (left === null && right === null) { return null; } } return new Set([ ...(left || []), ...(right || []) ]); } for (const block of collectBlocks()) { const foundCache = new Map(); function contains(value) { if (!foundCache.has(value)) { const regex = regexFor(value); regex.lastIndex = 0; const found = regex.test(block.text); regex.lastIndex = 0; foundCache.set(value, found); } return foundCache.get(value); } const positiveTerms = evaluate(expression, contains); // 未满足条件,或者只有排除条件,没有可高亮的关键词。 if (positiveTerms === null || positiveTerms.size === 0) { continue; } const positions = []; for (const term of positiveTerms) { const regex = regexFor(term); regex.lastIndex = 0; for (const match of block.text.matchAll(regex)) { positions.push({ start: match.index, end: match.index + match[0].length }); } } positions.sort( (a, b) => a.start - b.start || b.end - a.end ); // 不同条件可能匹配到同一个位置,避免重复计数。 let previousKey = null; for (const position of positions) { const key = `${position.start}:${position.end}`; if (key === previousKey) continue; previousKey = key; const range = rangeFromOffsets( block, position.start, position.end ); if (range) matches.push(range); } } // 逐个添加,避免结果太多时展开参数超出浏览器限制。 const highlight = new Highlight(); for (const range of matches) { highlight.add(range); } CSS.highlights.set(ALL_NAME, highlight); if (current > matches.length) { current = matches.length; } if (current > 0) { markActive(false); } updateCounter(); } function scrollToRange(range) { const rect = range.getBoundingClientRect(); if (!rect.width && !rect.height) return; let element = range.startContainer.parentElement; while (element && element !== document.body) { const style = getComputedStyle(element); if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1) { const viewport = element.getBoundingClientRect(); element.scrollBy({ top: rect.top - (viewport.top + viewport.height / 2), behavior: 'smooth' }); return; } element = element.parentElement; } window.scrollBy({ top: rect.top - window.innerHeight / 2, behavior: 'smooth' }); } function markActive(shouldScroll = true) { CSS.highlights.delete(ACTIVE_NAME); if (!current || !matches[current - 1]) return; const range = matches[current - 1]; const active = new Highlight(range); active.priority = 1; CSS.highlights.set(ACTIVE_NAME, active); if (shouldScroll) scrollToRange(range); } function move(direction) { if (!matches.length) return; current = current === 0 ? (direction > 0 ? 1 : matches.length) : ((current - 1 + direction + matches.length) % matches.length) + 1; markActive(); updateCounter(); } function close() { if (!host) return; observer?.disconnect(); observer = null; clearTimeout(refreshTimer); clearHighlights(); matches = []; current = 0; host.remove(); host = input = counter = null; } function open() { if (host) { input.focus(); input.select(); return; } if (!globalThis.CSS?.highlights || typeof Highlight === 'undefined') { alert('当前浏览器不支持 CSS Custom Highlight API。请使用较新的 Chrome。'); return; } installHighlightStyle(); host = makeElement('div'); host.id = 'tm-page-find-host'; const shadow = host.attachShadow({ mode: 'closed' }); const uiStyle = new CSSStyleSheet(); uiStyle.replaceSync(` :host { position: fixed; top: 12px; right: 12px; z-index: 2147483647; width: min(520px, calc(100vw - 24px)); color-scheme: light; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } .bar { display: flex; align-items: center; height: 54px; padding: 0 8px 0 16px; background: #fff; color: #333; border: 1px solid #ddd; border-radius: 12px; box-shadow: 0 6px 24px #0003; } .query { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: #222; font: inherit; } .query::placeholder { color: #888; } .counter { flex: none; min-width: 60px; text-align: center; color: #555; font-variant-numeric: tabular-nums; } .divider { width: 1px; height: 32px; margin: 0 7px; background: #ead5c8; } button { flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: #606060; font: 26px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; } button:hover { background: #f1f1f1; } button:focus-visible { outline: 2px solid #568cff; } `); shadow.adoptedStyleSheets = [uiStyle]; const bar = makeElement('div', 'bar'); input = makeElement('input', 'query'); input.type = 'text'; input.placeholder = '关键词:&且 |或 -排除'; input.setAttribute('aria-label', '搜索页面关键词'); counter = makeElement('span', 'counter', '0/0'); counter.setAttribute('aria-live', 'polite'); const divider = makeElement('span', 'divider'); function button(text, label, onClick) { const element = makeElement('button', '', text); element.type = 'button'; element.title = label; element.setAttribute('aria-label', label); element.addEventListener('click', onClick); return element; } const previous = button('↑', '上一个', () => move(-1)); const next = button('↓', '下一个', () => move(1)); const dismiss = button('×', '关闭', close); // 只调整箭头字号,按钮仍然是 38 × 38px,关闭图标不变。 previous.style.fontSize = '20px'; next.style.fontSize = '20px'; bar.append(input, counter, divider, previous, next, dismiss); shadow.append(bar); document.body.append(host); input.addEventListener('input', () => rebuild(true)); input.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); move(event.shiftKey ? -1 : 1); } }); // 页面动态更新时,重新建立 Range,避免高亮指向旧节点。 observer = new MutationObserver(() => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => rebuild(false), 200); }); observer.observe(document.body, { subtree: true, childList: true, characterData: true }); input.focus(); } document.addEventListener('keydown', event => { if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && event.code === 'KeyF') { event.preventDefault(); event.stopPropagation(); open(); } else if (event.key === 'Escape' && host) { event.preventDefault(); event.stopPropagation(); close(); } }, true); })();