// ==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, []),
};
})();