Greasy Fork

Greasy Fork is available in English.

多关键词页内查找

输入关键词,在当前网页高亮并逐个定位;默认隐藏,鼠标靠近右下角才出现小圆点(不闪烁)。

当前为 2026-02-13 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Multi-Keyword In-Page Finder
// @name:zh-CN   多关键词页内查找
// @namespace    https://github.com/ShualX
// @version      1.3
// @description  Paste keywords , highlight matches, and navigate. Hidden by default; proximity-reveal dot.
// @description:zh-CN  输入关键词,在当前网页高亮并逐个定位;默认隐藏,鼠标靠近右下角才出现小圆点(不闪烁)。
// @author       ShualX
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ====== UI config ======
  const REVEAL_ZONE_PX = 88;     // 右下角感应区:距离右边/下边 <= 88px 时显示圆点
  const DOT_SIZE = 24;          // 圆点尺寸 24x24
  const DOT_OFFSET = 14;        // 圆点距离右/下边距
  const SHOW_ICON = true;       // ✅ 想要“放大镜图标”就 true;只要纯圆点就 false
  const AUTO_HIDE_DELAY = 350;  // 鼠标离开感应区后延迟隐藏(ms),避免抖动

  const PANEL_ID = 'mk_panel_dot_v3';
  const STYLE_ID = 'mk_style_dot_v3';
  const DOT_ID = 'mk_dot_v3';

  // ----- CSS -----
  if (!document.getElementById(STYLE_ID)) {
    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = `
      #${PANEL_ID}, #${PANEL_ID} * { box-sizing: border-box !important; }

      /* Dot */
      #${DOT_ID}{
        position: fixed;
        right: ${DOT_OFFSET}px; bottom: ${DOT_OFFSET}px;
        width: ${DOT_SIZE}px; height: ${DOT_SIZE}px;
        border-radius: 999px;
        background: rgba(20,20,20,0.65);
        border: 1px solid rgba(255,255,255,0.22);
        box-shadow: 0 8px 18px rgba(0,0,0,0.25);
        cursor: pointer;
        z-index: 2147483647;

        opacity: 0;
        pointer-events: none;
        transform: translateY(6px);
        transition: opacity 140ms ease, transform 140ms ease, background 140ms ease;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      #${DOT_ID}.mk_show{
        opacity: 1;
        pointer-events: auto;
        transform: translateY(0);
      }
      #${DOT_ID}:hover{
        background: rgba(20,20,20,0.9);
      }
      #${DOT_ID} svg{
        width: 12px; height: 12px;
        opacity: .85;
      }

      /* Panel */
      #${PANEL_ID} {
        position: fixed; right: ${DOT_OFFSET}px; bottom: ${DOT_OFFSET + DOT_SIZE + 8}px;
        z-index: 2147483647;
        width: 320px; max-width: min(320px, calc(100vw - 24px));
        background: rgba(20,20,20,0.92); color: #fff;
        border-radius: 12px; padding: 10px;
        font: 12px/1.4 system-ui, -apple-system, Segoe UI, Arial;
        box-shadow: 0 10px 30px rgba(0,0,0,0.35);
        overflow: hidden;
      }
      #${PANEL_ID}.mk_hidden { display: none !important; }

      #${PANEL_ID} .mk_header {
        display:flex; align-items:center; justify-content:space-between; gap:8px;
        cursor: move; user-select: none;
      }
      #${PANEL_ID} .mk_title { font-weight: 700; }
      #${PANEL_ID} .mk_btn {
        background:#444; border:0; color:#fff; border-radius:8px;
        padding:4px 8px; cursor:pointer; line-height: 1;
      }
      #${PANEL_ID} textarea {
        width:100% !important; max-width:100% !important;
        height: 96px; max-height: 30vh;
        border-radius:10px; border:1px solid #555;
        padding:8px; background:#111; color:#fff;
        resize: vertical; outline: none;
      }
      #${PANEL_ID} .mk_row { display:flex; gap:8px; margin-top:8px; }
      #${PANEL_ID} .mk_primary { flex:1; background:#2f6fed; }
      #${PANEL_ID} .mk_secondary { flex:1; background:#555; }
      #${PANEL_ID} .mk_nav { flex:1; background:#333; }
      #${PANEL_ID} .mk_status { margin-top:8px; opacity:.85; word-break: break-word; }
      #${PANEL_ID} .mk_hint { margin-top:6px; opacity:.65; }
    `;
    document.documentElement.appendChild(style);
  }

  function ensureDot() {
    let dot = document.getElementById(DOT_ID);
    if (dot) return dot;

    dot = document.createElement('div');
    dot.id = DOT_ID;
    dot.title = '多关键词页内查找(点击展开/收起,Alt+K)';

    if (SHOW_ICON) {
      // tiny magnifier icon (inline SVG)
      dot.innerHTML = `
        <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M10.5 18.5a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z" stroke="white" stroke-width="2"/>
          <path d="M16.5 16.5 21 21" stroke="white" stroke-width="2" stroke-linecap="round"/>
        </svg>
      `;
    } else {
      dot.innerHTML = ''; // pure dot
    }

    document.documentElement.appendChild(dot);
    return dot;
  }

  function createPanel() {
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.classList.add('mk_hidden'); // 默认关闭
    panel.innerHTML = `
      <div class="mk_header" title="拖拽移动(Alt+K 显示/隐藏)">
        <div class="mk_title">多关键词页内查找</div>
        <div style="display:flex; gap:6px;">
          <button class="mk_btn" id="mk_hide" title="隐藏">×</button>
        </div>
      </div>

      <div style="margin-top:8px; opacity:.9;">粘贴Excel关键词(每行一个):</div>
      <textarea id="mk_input" placeholder="每行一个关键词,支持Excel直接粘贴"></textarea>

      <div class="mk_row">
        <button class="mk_btn mk_primary" id="mk_highlight">高亮</button>
        <button class="mk_btn mk_secondary" id="mk_clear">清除</button>
      </div>
      <div class="mk_row">
        <button class="mk_btn mk_nav" id="mk_prev">上一个</button>
        <button class="mk_btn mk_nav" id="mk_next">下一个</button>
      </div>

      <div class="mk_status" id="mk_status"></div>
      <div class="mk_hint">快捷键:Alt+K 面板;Alt+N/Alt+P 跳转</div>
    `;
    document.documentElement.appendChild(panel);
    return panel;
  }

  function getPanel() {
    return document.getElementById(PANEL_ID) || createPanel();
  }

  function togglePanel(force) {
    const panel = getPanel();
    if (typeof force === 'boolean') panel.classList.toggle('mk_hidden', !force);
    else panel.classList.toggle('mk_hidden');

    // 面板打开时,强制让圆点可见(方便关闭)
    const dot = ensureDot();
    const isOpen = !panel.classList.contains('mk_hidden');
    dot.classList.toggle('mk_show', isOpen || dot.classList.contains('mk_show'));
  }

  // ===== Proximity reveal logic (no flicker) =====
  const dot = ensureDot();
  let hideTimer = null;

  function showDot() {
    if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
    dot.classList.add('mk_show');
  }
  function hideDotLater() {
    if (hideTimer) clearTimeout(hideTimer);
    hideTimer = setTimeout(() => {
      const panelOpen = !getPanel().classList.contains('mk_hidden');
      if (!panelOpen) dot.classList.remove('mk_show');
    }, AUTO_HIDE_DELAY);
  }

  window.addEventListener('mousemove', (e) => {
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const nearRight = (vw - e.clientX) <= REVEAL_ZONE_PX;
    const nearBottom = (vh - e.clientY) <= REVEAL_ZONE_PX;

    const panelOpen = !getPanel().classList.contains('mk_hidden');
    if (panelOpen) {
      showDot();
      return;
    }

    if (nearRight && nearBottom) showDot();
    else hideDotLater();
  }, { passive: true });

  dot.addEventListener('click', () => togglePanel());

  // Alt+K toggle panel (works even when dot hidden)
  window.addEventListener('keydown', (e) => {
    if (e.altKey && e.key.toLowerCase() === 'k') togglePanel();
  });

  // ===== Drag panel =====
  function enableDrag(panel) {
    const header = panel.querySelector('.mk_header');
    let dragging = false;
    let startX = 0, startY = 0;
    let startRight = 0, startBottom = 0;

    header.addEventListener('mousedown', (e) => {
      if (e.target && e.target.tagName === 'BUTTON') return;
      dragging = true;
      startX = e.clientX;
      startY = e.clientY;
      const cs = getComputedStyle(panel);
      startRight = parseInt(cs.right, 10) || DOT_OFFSET;
      startBottom = parseInt(cs.bottom, 10) || (DOT_OFFSET + DOT_SIZE + 8);
      e.preventDefault();
    });

    window.addEventListener('mousemove', (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      panel.style.right = `${Math.max(8, startRight - dx)}px`;
      panel.style.bottom = `${Math.max(8, startBottom - dy)}px`;
    }, { passive: true });

    window.addEventListener('mouseup', () => dragging = false);
  }

  // ===== Highlight engine =====
  const MARK_CLASS = 'mk_mark';
  let marks = [];
  let activeIndex = -1;

  function $(sel) { return getPanel().querySelector(sel); }
  function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

  function getKeywords() {
    const raw = ($('#mk_input')?.value || '');
    const parts = raw
      .split(/\r?\n/)
      .flatMap(line => line.split('\t'))
      .map(s => s.trim())
      .filter(Boolean);

    const seen = new Set();
    const unique = [];
    for (const p of parts) if (!seen.has(p)) { seen.add(p); unique.push(p); }
    return unique;
  }

  function setStatus(msg) {
    const el = $('#mk_status');
    if (el) el.textContent = msg;
  }

  function clearHighlights() {
    const nodes = Array.from(document.querySelectorAll(`span.${MARK_CLASS}`));
    for (const n of nodes) {
      const parent = n.parentNode;
      if (!parent) continue;
      parent.replaceChild(document.createTextNode(n.textContent), n);
      parent.normalize();
    }
    marks = [];
    activeIndex = -1;
    setStatus('已清除高亮。');
  }

  function shouldSkipNode(node, panel) {
    if (!node || !node.parentElement) return true;
    const tag = node.parentElement.tagName;
    if (['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION'].includes(tag)) return true;
    if (panel.contains(node.parentElement)) return true;
    return false;
  }

  function highlightAll() {
    const panel = getPanel();
    clearHighlights();

    const keywords = getKeywords();
    if (keywords.length === 0) { setStatus('没有检测到关键词(请粘贴一列词)。'); return; }

    const sorted = [...keywords].sort((a, b) => b.length - a.length);
    const joined = sorted.map(escapeRegExp).join('|');

    const MAX_TOTAL_CHARS = 20000;
    if (joined.length > MAX_TOTAL_CHARS) {
      setStatus(`关键词过多/过长(规则约 ${joined.length} 字符)。建议分批(每次 200 个左右)。`);
      return;
    }

    const regex = new RegExp(joined, 'gi');

    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode: (node) => {
        if (shouldSkipNode(node, panel)) return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue) return NodeFilter.FILTER_SKIP;
        regex.lastIndex = 0;
        if (!regex.test(node.nodeValue)) return NodeFilter.FILTER_SKIP;
        regex.lastIndex = 0;
        return NodeFilter.FILTER_ACCEPT;
      }
    });

    const toProcess = [];
    while (walker.nextNode()) toProcess.push(walker.currentNode);

    for (const textNode of toProcess) {
      const text = textNode.nodeValue;
      regex.lastIndex = 0;

      let match, lastIdx = 0;
      const frag = document.createDocumentFragment();

      while ((match = regex.exec(text)) !== null) {
        const start = match.index;
        const end = start + match[0].length;

        if (start > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, start)));

        const span = document.createElement('span');
        span.className = MARK_CLASS;
        span.textContent = text.slice(start, end);
        span.style.cssText = 'background:#ffeb3b;color:#000;padding:0 2px;border-radius:4px;';
        frag.appendChild(span);

        lastIdx = end;
      }
      if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));

      const parent = textNode.parentNode;
      if (parent) parent.replaceChild(frag, textNode);
    }

    marks = Array.from(document.querySelectorAll(`span.${MARK_CLASS}`));
    if (marks.length === 0) { setStatus(`未命中:关键词 ${keywords.length} 个。`); return; }

    activeIndex = 0;
    focusActive();
    setStatus(`命中 ${marks.length} 处(关键词 ${keywords.length} 个)。`);
  }

  function focusActive() {
    const cur = marks[activeIndex];
    if (!cur) return;
    cur.style.outline = '2px solid #ff5722';
    cur.scrollIntoView({ behavior: 'smooth', block: 'center' });
    setTimeout(() => { cur.style.outline = 'none'; }, 600);
    setStatus(`定位到第 ${activeIndex + 1}/${marks.length} 处`);
  }

  function next() { if (!marks.length) return; activeIndex = (activeIndex + 1) % marks.length; focusActive(); }
  function prev() { if (!marks.length) return; activeIndex = (activeIndex - 1 + marks.length) % marks.length; focusActive(); }

  // ----- Bind panel events once (lazy) -----
  let bound = false;
  function bindOnce() {
    if (bound) return;
    bound = true;

    const panel = getPanel();
    enableDrag(panel);

    panel.querySelector('#mk_hide').onclick = () => togglePanel(false);
    panel.querySelector('#mk_highlight').onclick = highlightAll;
    panel.querySelector('#mk_clear').onclick = clearHighlights;
    panel.querySelector('#mk_next').onclick = next;
    panel.querySelector('#mk_prev').onclick = prev;

    window.addEventListener('keydown', (e) => {
      if (e.altKey && e.key.toLowerCase() === 'n') next();
      if (e.altKey && e.key.toLowerCase() === 'p') prev();
    });
  }

  const _togglePanel = togglePanel;
  togglePanel = function (force) {
    bindOnce();
    return _togglePanel(force);
  };

})();