您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
弹窗管理 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或关注我们的公众号极客氢云获取最新地址