ChatGPT 5-添加高亮URL

弹窗管理 GitHub/raw txt 列表(最多5条),前置复选框控制是否启用,高亮不会改动原文字,24h 缓存,懒加载,移动端友好。

// ==UserScript==
// @name         ChatGPT 5-添加高亮URL
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  弹窗管理 GitHub/raw txt 列表(最多5条),前置复选框控制是否启用,高亮不会改动原文字,24h 缓存,懒加载,移动端友好。
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  /* ================== 配置 ================== */
  const MAX_URLS = 5;
  const URLS_KEY = 'gh_manager_urls_v2';          // 存储 [{url, enabled, name?}]
  const CACHE_KEY = 'gh_manager_cache_v2';        // 存储 map: url -> {words, fetchedAt}
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;       // 24 小时
  const HIGHLIGHT_CLASS = 'ghm-word-highlight';
  const HIGHLIGHT_ATTR = 'data-ghm-word';
  const BATCH_NODE_LIMIT = 200;                   // 每批处理文本节点数,防卡顿
  const IDLE_TIMEOUT = 600;
  /* ========================================== */

  /******************** 样式 & UI ********************/
  GM_addStyle(`
    .${HIGHLIGHT_CLASS} {
      background: linear-gradient(90deg, rgba(255,250,200,0.95), rgba(255,235,150,0.95));
      border-radius: 3px;
      padding: 0 2px;
      line-height: inherit;
      -webkit-box-decoration-break: clone; box-decoration-break: clone;
      cursor: text;
    }
    .${HIGHLIGHT_CLASS}::selection { background: rgba(180,200,255,0.6); }

    /* 管理弹窗 */
    #ghm-modal {
      position: fixed;
      z-index: 2147483647;
      left: 50%;
      top: 8%;
      transform: translateX(-50%);
      width: min(720px, 94%);
      max-height: 84%;
      overflow: auto;
      background: #fff;
      color: #111;
      border: 1px solid rgba(0,0,0,.12);
      border-radius: 10px;
      box-shadow: 0 6px 28px rgba(0,0,0,.25);
      padding: 14px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
    }
    #ghm-modal h3 { margin: 0 0 8px 0; font-size: 16px; }
    #ghm-modal .ghm-row { display:flex; align-items:center; gap:8px; margin:6px 0; word-break:break-all; }
    #ghm-modal .ghm-row input[type="text"] { flex:1; padding:6px; border-radius:6px; border:1px solid #ddd; }
    #ghm-modal .ghm-url { flex:1; font-size:13px; color:#0b5cff; }
    #ghm-modal button { padding:6px 8px; border-radius:6px; border:1px solid #ccc; background:#f8f8f8; cursor:pointer; }
    #ghm-modal .ghm-actions { display:flex; justify-content:space-between; gap:8px; margin-top:10px; }
    #ghm-modal .ghm-small { font-size:12px; color:#666; }
    #ghm-modal .ghm-delete { color:#b00020; border-color: rgba(176,0,32,.12); background:#fff6f6; }
    @media (max-width:600px){
      #ghm-modal { top: 4%; width: 96%; padding:10px; border-radius:8px; }
    }
  `);

  /******************** 存储封装(兼容) ********************/
  async function getStored(key, fallback = null) {
    try {
      const v = await GM_getValue(key);
      return v === undefined ? fallback : v;
    } catch (e) {
      try {
        const raw = localStorage.getItem(key);
        return raw ? JSON.parse(raw) : fallback;
      } catch {
        return fallback;
      }
    }
  }

  async function setStored(key, value) {
    try {
      await GM_setValue(key, value);
    } catch (e) {
      try {
        localStorage.setItem(key, JSON.stringify(value));
      } catch {}
    }
  }

  /******************** 网络请求 & 缓存 ********************/
  function fetchText(url) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest === 'function') {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          headers: { 'Cache-Control': 'no-cache' },
          onload: res => {
            if (res.status >= 200 && res.status < 300) resolve(res.responseText);
            else reject(new Error('HTTP ' + res.status));
          },
          onerror: err => reject(err),
          ontimeout: () => reject(new Error('timeout'))
        });
      } else {
        fetch(url, { cache: 'no-cache' }).then(r => {
          if (!r.ok) throw new Error('HTTP ' + r.status);
          return r.text();
        }).then(t => resolve(t)).catch(err => reject(err));
      }
    });
  }

  async function loadListsFromUrls(urlObjects) {
    const cache = (await getStored(CACHE_KEY, {})) || {};
    const now = Date.now();
    const allWords = new Set();

    for (const obj of urlObjects) {
      const url = obj.url;
      try {
        const entry = cache[url];
        if (entry && entry.words && (now - entry.fetchedAt < CACHE_TTL_MS)) {
          for (const w of entry.words) allWords.add(w);
          continue;
        }
        // fetch fresh
        const txt = await fetchText(url);
        const words = parseWordList(txt);
        cache[url] = { words, fetchedAt: now, url };
        for (const w of words) allWords.add(w);
      } catch (e) {
        console.warn('ghm: fetch failed for', url, e);
        // fallback to cache if present
        const entry = cache[url];
        if (entry && entry.words) {
          for (const w of entry.words) allWords.add(w);
        } // else skip
      }
    }

    await setStored(CACHE_KEY, cache);
    return Array.from(allWords);
  }

  function parseWordList(text) {
    const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
    const set = new Set();
    for (let l of lines) {
      if (l.startsWith('#') || l.startsWith('//')) continue;
      const m = l.match(/^([A-Za-z\'\-]+)/);
      if (m) set.add(m[1].toLowerCase());
    }
    return Array.from(set);
  }

  /******************** 正则 & 词形(安全) ********************/
  function escapeRegex(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  // 安全版:仅对长度 >= 3 的词启用后缀匹配
  function regexForWord(word) {
    const w = word.trim();
    if (!w) return null;
    const esc = escapeRegex(w);
    if (w.length >= 3) {
      // 整体匹配包含后缀,group 捕获 base word 为识别使用,但我们会用 match[0] 显示原文
      return new RegExp(`\\b(?:${esc})(?:s|es|ed|ing|'s)?\\b`, 'giu');
    } else {
      return new RegExp(`\\b(?:${esc})\\b`, 'giu');
    }
  }

  /******************** 高亮核心(不会篡改原文字) ********************/
  let currentRegexList = []; // [{word: base, regex: RegExp}...]
  let observer = null;
  let mutationTimer = null;
  let active = true;

  function createTextNodeWalker(root = document.body) {
    return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode: node => {
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        const parent = node.parentElement;
        if (!parent) return NodeFilter.FILTER_REJECT;
        // 排除某些标签与我们自身高亮
        if (/^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA|CODE|PRE|INPUT)$/i.test(parent.tagName)) return NodeFilter.FILTER_REJECT;
        if (parent.closest && parent.closest('.' + HIGHLIGHT_CLASS)) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    }, false);
  }

  // 在单个 TextNode 上进行替换(构造 fragment,保留完整匹配文本)
  function replaceInTextNode(textNode, regexList) {
    const original = textNode.nodeValue;
    if (!original || !original.trim()) return 0;

    // 快速检测是否有匹配
    let any = false;
    for (const { regex } of regexList) {
      regex.lastIndex = 0;
      if (regex.test(original)) { any = true; break; }
    }
    if (!any) return 0;

    const frag = document.createDocumentFragment();
    let remaining = original;
    // 迭代:在 remaining 中找 earliest match(多个 regex 比较)
    while (remaining.length > 0) {
      let earliest = null; // {start, end, matchText, word}
      for (const { word, regex } of regexList) {
        regex.lastIndex = 0;
        const m = regex.exec(remaining);
        if (m) {
          const s = m.index;
          const e = s + m[0].length;
          if (earliest === null || s < earliest.start) {
            earliest = { start: s, end: e, matchText: m[0], word };
          }
        }
      }

      if (!earliest) {
        // no more matches
        frag.appendChild(document.createTextNode(remaining));
        break;
      }

      // prefix
      if (earliest.start > 0) {
        frag.appendChild(document.createTextNode(remaining.slice(0, earliest.start)));
      }
      // matched span — **关键**:使用 matchText 完整呈现(保留后缀/大小写)
      const span = document.createElement('span');
      span.className = HIGHLIGHT_CLASS;
      span.setAttribute(HIGHLIGHT_ATTR, earliest.word || ''); // 可用于后续统计/样式
      span.textContent = remaining.slice(earliest.start, earliest.end); // preserved text
      frag.appendChild(span);

      // advance
      remaining = remaining.slice(earliest.end);
    }

    // 替换
    textNode.parentNode.replaceChild(frag, textNode);
    return 1;
  }

  // 分片处理大量节点,避免卡顿
  function processAndHighlight(root = document.body) {
    if (!active || !currentRegexList.length) return;
    const walker = createTextNodeWalker(root);
    const nodes = [];
    let node;
    while ((node = walker.nextNode())) {
      nodes.push(node);
      if (nodes.length >= 8000) break; // safety cap
    }

    let i = 0;
    function processChunk(deadline) {
      let count = 0;
      const limit = BATCH_NODE_LIMIT;
      while (i < nodes.length && count < limit) {
        try {
          replaceInTextNode(nodes[i], currentRegexList);
        } catch (e) { /* ignore per-node errors */ }
        i++; count++;
      }
      if (i < nodes.length) {
        if (typeof requestIdleCallback === 'function') {
          requestIdleCallback(processChunk, { timeout: IDLE_TIMEOUT });
        } else {
          setTimeout(processChunk, 50);
        }
      }
    }

    if (typeof requestIdleCallback === 'function') {
      requestIdleCallback(processChunk, { timeout: IDLE_TIMEOUT });
    } else {
      setTimeout(processChunk, 200);
    }
  }

  // 清除页面上我们加的高亮(恢复原文)
  function clearHighlights(root = document.body) {
    const spans = root.querySelectorAll('.' + HIGHLIGHT_CLASS);
    for (const s of spans) {
      s.replaceWith(document.createTextNode(s.textContent));
    }
  }

  // 监视动态新增内容
  function startObserver() {
    if (observer) observer.disconnect();
    observer = new MutationObserver(muts => {
      if (!active) return;
      if (mutationTimer) clearTimeout(mutationTimer);
      mutationTimer = setTimeout(() => {
        for (const m of muts) {
          if (m.addedNodes && m.addedNodes.length) {
            for (const nd of m.addedNodes) {
              if (nd.nodeType === Node.ELEMENT_NODE) processAndHighlight(nd);
              else if (nd.nodeType === Node.TEXT_NODE) {
                try { replaceInTextNode(nd, currentRegexList); } catch {}
              }
            }
          }
        }
      }, 200);
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  /******************** 构建 regex & 应用 ********************/
  async function buildRegexesAndApply(wordList) {
    // 先清理旧高亮,重建 regex 列表,再 apply
    clearHighlights();
    const list = [];
    for (const w of wordList) {
      const r = regexForWord(w);
      if (r) list.push({ word: w, regex: r });
    }
    currentRegexList = list;
    // schedule idle apply & start observing
    if (typeof requestIdleCallback === 'function') {
      requestIdleCallback(() => {
        processAndHighlight(document.body);
        startObserver();
      }, { timeout: IDLE_TIMEOUT });
    } else {
      setTimeout(() => {
        processAndHighlight(document.body);
        startObserver();
      }, 300);
    }
  }

  async function loadAndApplyFromStoredUrls() {
    const urls = await getStored(URLS_KEY, []);
    if (!Array.isArray(urls) || urls.length === 0) {
      currentRegexList = [];
      return;
    }
    const limited = urls.slice(0, MAX_URLS);
    const enabled = limited.filter(u => u.enabled);
    if (!enabled.length) {
      // 取消所有高亮
      clearHighlights();
      currentRegexList = [];
      return;
    }
    const words = await loadListsFromUrls(enabled);
    if (!words || words.length === 0) {
      currentRegexList = [];
      return;
    }
    await buildRegexesAndApply(words);
  }

  /******************** 管理界面(弹窗) ********************/
  async function openManager() {
    // 如果已经存在,勿重复打开
    if (document.getElementById('ghm-modal')) return;

    // container
    const modal = document.createElement('div');
    modal.id = 'ghm-modal';
    modal.innerHTML = `
      <h3>单词列表管理(最多 ${MAX_URLS} 条)</h3>
      <div id="ghm-list"></div>
      <div class="ghm-row">
        <input type="text" id="ghm-input" placeholder="输入 raw txt 文件 URL (例如 GitHub raw 链接)" />
        <button id="ghm-add">添加</button>
      </div>
      <div class="ghm-actions">
        <div class="ghm-small">勾选启用后会从该 URL 拉取单词并实时高亮,取消勾选则移除高亮。缓存 24 小时。</div>
        <div>
          <button id="ghm-clear-cache">清除缓存</button>
          <button id="ghm-close">关闭</button>
        </div>
      </div>
    `;
    document.body.appendChild(modal);

    // render list
    async function renderList() {
      const listEl = modal.querySelector('#ghm-list');
      listEl.innerHTML = '';
      const urls = (await getStored(URLS_KEY, [])) || [];
      urls.slice(0, MAX_URLS).forEach((item, idx) => {
        const row = document.createElement('div');
        row.className = 'ghm-row';
        row.innerHTML = `
          <input type="checkbox" data-idx="${idx}" ${item.enabled ? 'checked' : ''}/>
          <div class="ghm-url" title="${item.url}">${item.url}</div>
          <button data-del="${idx}" class="ghm-delete">删除</button>
        `;
        listEl.appendChild(row);
      });

      // bind events
      listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
        cb.addEventListener('change', async (e) => {
          const idx = Number(e.target.dataset.idx);
          const urls = (await getStored(URLS_KEY, [])) || [];
          if (!urls[idx]) return;
          urls[idx].enabled = !!e.target.checked;
          await setStored(URLS_KEY, urls);
          // 重新加载与应用
          await loadAndApplyFromStoredUrls();
        });
      });

      listEl.querySelectorAll('button[data-del]').forEach(btn => {
        btn.addEventListener('click', async (e) => {
          const idx = Number(e.target.dataset.del);
          let urls = (await getStored(URLS_KEY, [])) || [];
          urls.splice(idx, 1);
          await setStored(URLS_KEY, urls);
          renderList();
          await loadAndApplyFromStoredUrls();
        });
      });
    }

    // add button
    modal.querySelector('#ghm-add').addEventListener('click', async () => {
      const input = modal.querySelector('#ghm-input');
      const url = (input.value || '').trim();
      if (!url) { alert('请输入 URL'); return; }
      let urls = (await getStored(URLS_KEY, [])) || [];
      if (urls.length >= MAX_URLS) { alert('已达最大数量'); return; }
      // 防重复
      if (urls.some(u => u.url === url)) { alert('该 URL 已存在'); input.value = ''; return; }
      urls.push({ url, enabled: true });
      await setStored(URLS_KEY, urls);
      input.value = '';
      await renderList();
      await loadAndApplyFromStoredUrls();
    });

    // clear cache
    modal.querySelector('#ghm-clear-cache').addEventListener('click', async () => {
      await setStored(CACHE_KEY, {});
      alert('缓存已清除(下次拉取会重新获取)');
    });

    // close
    modal.querySelector('#ghm-close').addEventListener('click', () => {
      modal.remove();
    });

    // initial render
    await renderList();
  }

  /******************** init & 菜单 ********************/
  // 初始化存储
  (async () => {
    const urls = await getStored(URLS_KEY, null);
    if (!urls) await setStored(URLS_KEY, []);
  })();

  GM_registerMenuCommand('管理单词 URL(弹窗)', openManager);

  // 启动主流程(懒)  
  if (typeof requestIdleCallback === 'function') {
    requestIdleCallback(() => loadAndApplyFromStoredUrls(), { timeout: IDLE_TIMEOUT });
  } else {
    setTimeout(() => loadAndApplyFromStoredUrls(), 700);
  }

  // 暴露调试接口(可选)
  window.__ghm = {
    reload: loadAndApplyFromStoredUrls,
    clearHighlights: () => clearHighlights(document.body),
    openManager,
    getUrls: async () => await getStored(URLS_KEY, []),
  };

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址