Greasy Fork

Greasy Fork is available in English.

多关键词页内查找

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

您需要先安装一款用户脚本管理器扩展,例如 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);
  };

})();