// ==UserScript== // @name 多语种生词高亮助手 // @namespace 公众号: 337自修室 // @version 26.06.23.1 // @description 自定义多语种网页生词高亮工具,支持欧路生词本自动选择与覆盖/合并同步模式。 // @author Domus // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect my.eudic.net // @license MIT // ==/UserScript== (function() { 'use strict'; // 初始化默认配置 const defaultConfig = { words: ['English', '日本語', 'العربية', '...'], bgColor: '#EAEAEA', textColor: '#000000', highlightMode: 'all', whitelist: [], bookId: '', syncMode: 'overwrite' }; let config = GM_getValue('jp_hl_config', defaultConfig); if (!config.highlightMode) config.highlightMode = 'all'; if (!config.whitelist) config.whitelist = []; if (config.bookId === undefined) config.bookId = ''; if (!config.syncMode) config.syncMode = 'overwrite'; let wordsRegex = null; function compileRegex() { if (!config.words || config.words.length === 0) { wordsRegex = null; return; } const sortedWords = [...config.words].sort((a, b) => b.length - a.length); const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = sortedWords.map(word => { const escaped = escapeRegExp(word); if (/^[a-zA-Z\s]+$/.test(word)) { return `\\b${escaped}\\b`; } return escaped; }).join('|'); wordsRegex = new RegExp(`(${pattern})`, 'gi'); } compileRegex(); // 判断当前页面是否应该高亮 function shouldHighlight() { if (config.highlightMode === 'all') return true; if (config.highlightMode === 'whitelist') { const currentHost = window.location.hostname; return config.whitelist.some(domain => currentHost.includes(domain)); } return true; } function highlightText(node) { if (!shouldHighlight()) return; if (!wordsRegex) return; const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode: function(textNode) { const parentElement = textNode.parentElement; const parentName = parentElement.nodeName; const parentClass = parentElement.className; if (['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'RT', 'RP', 'NOSCRIPT'].includes(parentName)) { return NodeFilter.FILTER_REJECT; } if (parentClass === 'jp-hl-word' || parentElement.closest('#jp-hl-settings') || parentElement.closest('a, button, [class*="button"], [class*="btn"], nav, header, footer')) { return NodeFilter.FILTER_REJECT; } return textNode.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }); const nodesToReplace = []; let currentNode; while ((currentNode = walker.nextNode())) { if (wordsRegex.test(currentNode.data)) { nodesToReplace.push(currentNode); } } nodesToReplace.forEach(textNode => { const span = document.createElement('span'); span.innerHTML = textNode.data.replace(wordsRegex, (match) => { return `${match}`; }); textNode.parentNode.replaceChild(span, textNode); }); } function observeDOM() { const observer = new MutationObserver((mutations) => { if (!shouldHighlight()) return; mutations.forEach(mutation => { if (mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(addedNode => { if (addedNode.nodeType === Node.ELEMENT_NODE) { highlightText(addedNode); } else if (addedNode.nodeType === Node.TEXT_NODE) { highlightText(addedNode.parentNode); } }); } }); }); observer.observe(document.body, { childList: true, subtree: true }); } // ---------- 欧路同步功能 ---------- async function syncEudic() { const limit = 4000; let start = 0; let allWords = []; let hasMore = true; const statusEl = document.getElementById('jp-hl-sync-status'); const btn = document.getElementById('jp-hl-sync-eudic'); const bookSelect = document.getElementById('jp-hl-book-select'); const syncModeSelect = document.getElementById('jp-hl-sync-mode'); if (!btn) return; const bookId = bookSelect ? bookSelect.value : ''; const syncMode = syncModeSelect ? syncModeSelect.value : 'overwrite'; config.bookId = bookId; config.syncMode = syncMode; GM_setValue('jp_hl_config', config); btn.disabled = true; btn.textContent = '同步中...'; if (statusEl) statusEl.textContent = `正在获取${bookId ? '指定' : '默认/全部'}生词...`; while (hasMore) { try { let url = `https://my.eudic.net/StudyList/WordsDataSource?start=${start}&length=${limit}`; if (bookId) { url += `&categoryId=${encodeURIComponent(bookId)}`; } const result = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, withCredentials: true, onload: function(response) { try { resolve(JSON.parse(response.responseText)); } catch(e) { reject(e); } }, onerror: reject, ontimeout: () => reject(new Error("请求超时")) }); }); if (result.data && Array.isArray(result.data) && result.data.length > 0) { const words = result.data.map(item => item.uuid.trim()).filter(w => w !== ''); allWords = allWords.concat(words); start += limit; await new Promise(resolve => setTimeout(resolve, 500)); } else { hasMore = false; } } catch(e) { if (statusEl) statusEl.textContent = '同步失败:' + e.message; btn.disabled = false; btn.textContent = '同步生词'; alert('同步失败,请确保已登录欧路网站 (my.eudic.net) 并检查网络。'); return; } } const uniqueWords = [...new Set(allWords)]; if (uniqueWords.length === 0) { if (statusEl) statusEl.textContent = '该生词本为空或未登录'; btn.disabled = false; btn.textContent = '同步生词'; alert('未获取到任何生词,请确认已登录且有内容。'); return; } let finalWords; if (syncMode === 'overwrite') { finalWords = uniqueWords; } else { const existing = config.words || []; finalWords = [...new Set([...existing, ...uniqueWords])]; } config.words = finalWords; GM_setValue('jp_hl_config', config); compileRegex(); const wordsTextarea = document.getElementById('jp-hl-words'); if (wordsTextarea) { wordsTextarea.value = finalWords.join('\n'); } if (statusEl) statusEl.textContent = `同步成功,共 ${finalWords.length} 个单词 (${syncMode === 'overwrite' ? '覆盖' : '合并'})`; btn.disabled = false; btn.textContent = '同步生词'; alert(`同步成功!共 ${finalWords.length} 个生词,即将刷新页面。`); location.reload(); } // ---------- 抓取生词本列表 ---------- function fetchBookList() { const btn = document.getElementById('jp-hl-refresh-books'); const select = document.getElementById('jp-hl-book-select'); const status = document.getElementById('jp-hl-book-status'); if (!btn || !select) return; btn.disabled = true; btn.textContent = '获取中...'; if (status) status.textContent = '正在请求欧路页面...'; GM_xmlhttpRequest({ method: "GET", url: "https://my.eudic.net/studylist", withCredentials: true, onload: function(response) { try { const html = response.responseText; const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const items = doc.querySelectorAll('a.new_cateitem_click'); let options = []; options.push({ id: '', name: '📚 默认/全部生词本' }); items.forEach(el => { const id = el.getAttribute('data-id'); const title = el.getAttribute('title') || el.textContent.trim(); if (id !== null && id !== undefined && id !== '-1') { options.push({ id: id, name: title }); } }); const seen = new Set(); options = options.filter(opt => { const key = opt.id; if (seen.has(key)) return false; seen.add(key); return true; }); select.innerHTML = ''; options.forEach(opt => { const option = document.createElement('option'); option.value = opt.id; option.textContent = opt.name; select.appendChild(option); }); if (config.bookId) { select.value = config.bookId; } else { select.value = ''; } if (status) status.textContent = `获取成功,共 ${options.length} 个生词本`; config.bookId = select.value; GM_setValue('jp_hl_config', config); } catch(e) { if (status) status.textContent = '解析失败:' + e.message; alert('解析生词本列表失败,请确保已登录欧路。'); } btn.disabled = false; btn.textContent = '刷新词本'; }, onerror: function(err) { if (status) status.textContent = '请求失败'; alert('无法访问欧路页面,请确保已登录 my.eudic.net 并检查网络。'); btn.disabled = false; btn.textContent = '刷新词本'; }, ontimeout: function() { if (status) status.textContent = '请求超时'; alert('请求超时,请检查网络。'); btn.disabled = false; btn.textContent = '刷新词本'; } }); } // --------------------------------- // UI 设置面板(新布局) function createSettingsPanel() { if (document.getElementById('jp-hl-settings')) return; const panel = document.createElement('div'); panel.id = 'jp-hl-settings'; panel.innerHTML = `

生词高亮设置

×
📌 操作提示:
1. 请先登录欧路词典网页(https://my.eudic.net/
2. 点击“刷新词本”获取生词本(需已登录)
3. 尽量在欧路内修改,然后使用“覆盖列表”模式同步
4. 建议搭配欧路词典的鼠标取词功能使用
高亮配色
背景颜色 (HEX):
文字颜色 (HEX):
版本 26.06.23
`; document.body.appendChild(panel); // UI 交互绑定 document.getElementById('jp-hl-close').onclick = () => panel.remove(); const bgPicker = document.getElementById('jp-hl-bg'); const bgHex = document.getElementById('jp-hl-bg-hex'); const textPicker = document.getElementById('jp-hl-text'); const textHex = document.getElementById('jp-hl-text-hex'); bgPicker.addEventListener('input', e => bgHex.value = e.target.value); bgHex.addEventListener('input', e => bgPicker.value = e.target.value); textPicker.addEventListener('input', e => textHex.value = e.target.value); textHex.addEventListener('input', e => textPicker.value = e.target.value); const modeRadios = document.querySelectorAll('input[name="jp-hl-mode"]'); const whitelistContainer = document.getElementById('jp-hl-whitelist-container'); modeRadios.forEach(radio => { radio.addEventListener('change', (e) => { whitelistContainer.style.display = e.target.value === 'whitelist' ? 'flex' : 'none'; }); }); // 保存逻辑 document.getElementById('jp-hl-save').onclick = () => { const rawWords = document.getElementById('jp-hl-words').value; const parsedWords = [...new Set(rawWords.split('\n').map(w => w.trim()).filter(w => w !== ''))]; const selectedMode = document.querySelector('input[name="jp-hl-mode"]:checked').value; const rawWhitelist = document.getElementById('jp-hl-whitelist').value; const parsedWhitelist = [...new Set(rawWhitelist.split('\n').map(d => d.trim()).filter(d => d !== ''))]; const bookSelect = document.getElementById('jp-hl-book-select'); const bookId = bookSelect ? bookSelect.value : ''; const syncMode = document.getElementById('jp-hl-sync-mode').value; config.bgColor = bgHex.value; config.textColor = textHex.value; config.words = parsedWords; config.highlightMode = selectedMode; config.whitelist = parsedWhitelist; config.bookId = bookId; config.syncMode = syncMode; GM_setValue('jp_hl_config', config); compileRegex(); alert('保存成功!正在刷新页面...'); location.reload(); }; document.getElementById('jp-hl-sync-eudic').addEventListener('click', syncEudic); document.getElementById('jp-hl-refresh-books').addEventListener('click', fetchBookList); // 新增按钮事件 const addBtn = document.getElementById('jp-hl-add-domain'); const removeBtn = document.getElementById('jp-hl-remove-domain'); const domainStatus = document.getElementById('jp-hl-domain-status'); const whitelistTextarea = document.getElementById('jp-hl-whitelist'); if (addBtn) { addBtn.addEventListener('click', () => { const currentHost = window.location.hostname; if (!currentHost) { domainStatus.textContent = '无法获取当前域名'; return; } const raw = whitelistTextarea.value; const list = raw.split('\n').map(d => d.trim()).filter(d => d !== ''); if (list.includes(currentHost)) { domainStatus.textContent = `域名 ${currentHost} 已在白名单中`; return; } list.push(currentHost); whitelistTextarea.value = list.join('\n'); config.whitelist = list; GM_setValue('jp_hl_config', config); compileRegex(); domainStatus.textContent = `已添加 ${currentHost},刷新页面生效`; location.reload(); }); } if (removeBtn) { removeBtn.addEventListener('click', () => { const currentHost = window.location.hostname; if (!currentHost) { domainStatus.textContent = '无法获取当前域名'; return; } const raw = whitelistTextarea.value; let list = raw.split('\n').map(d => d.trim()).filter(d => d !== ''); const index = list.indexOf(currentHost); if (index === -1) { domainStatus.textContent = `域名 ${currentHost} 不在白名单中`; return; } list.splice(index, 1); whitelistTextarea.value = list.join('\n'); config.whitelist = list; GM_setValue('jp_hl_config', config); compileRegex(); domainStatus.textContent = `已删除 ${currentHost},刷新页面生效`; location.reload(); }); } // 自动加载生词本列表 setTimeout(() => { const select = document.getElementById('jp-hl-book-select'); if (select && select.options.length <= 1) { fetchBookList(); } }, 1000); } GM_addStyle(` /* 基础重置 - 仅针对可能被外部干扰的属性 */ #jp-hl-settings, #jp-hl-settings * { box-sizing: border-box !important; font-family: inherit !important; line-height: inherit !important; } /* 面板容器样式 */ #jp-hl-settings { display: block !important; position: fixed !important; top: 50px !important; right: 50px !important; width: 380px !important; background: #fff !important; border: 1px solid #ccc !important; border-radius: 8px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; padding: 15px !important; z-index: 999999 !important; font-family: sans-serif !important; color: #333 !important; max-height: 90vh !important; overflow-y: auto !important; overflow-x: hidden !important; line-height: normal !important; } /* 内部元素样式重置与设置 */ #jp-hl-settings .jp-hl-header { display: flex !important; justify-content: space-between !important; align-items: center !important; border-bottom: 1px solid #eee !important; padding-bottom: 10px !important; margin-bottom: 10px !important; } #jp-hl-settings .jp-hl-header h3 { margin: 0 !important; font-size: 16px !important; font-weight: bold !important; } #jp-hl-settings #jp-hl-close { font-size: 20px !important; cursor: pointer !important; color: #999 !important; } #jp-hl-settings #jp-hl-close:hover { color: #333 !important; } #jp-hl-settings .jp-hl-row-col { display: flex !important; flex-direction: column !important; margin-bottom: 15px !important; } #jp-hl-settings .jp-hl-row-col textarea { width: 100% !important; border: 1px solid #ccc !important; border-radius: 4px !important; padding: 5px !important; resize: vertical !important; box-sizing: border-box !important; font-family: inherit !important; font-size: 12px !important; line-height: 1.5 !important; background: #fff !important; color: #333 !important; } #jp-hl-settings .jp-hl-actions { margin-top: 10px !important; display: flex !important; justify-content: space-between !important; align-items: center !important; } #jp-hl-settings .jp-hl-actions button { background: #189fd8 !important; color: #fff !important; border: none !important; padding: 8px 12px !important; border-radius: 4px !important; cursor: pointer !important; font-size: 14px !important; } #jp-hl-settings .jp-hl-actions button:hover { background: #1588b6 !important; } /* 通用按钮样式 */ #jp-hl-settings button { font-family: inherit !important; background: #f0f0f0 !important; border: 1px solid #ccc !important; padding: 4px 12px !important; border-radius: 4px !important; cursor: pointer !important; font-size: 12px !important; color: #333 !important; } #jp-hl-settings button:hover { background: #e0e0e0 !important; } #jp-hl-settings button:disabled { opacity: 0.6 !important; cursor: not-allowed !important; } #jp-hl-settings select { font-family: inherit !important; background: #fff !important; border: 1px solid #ccc !important; border-radius: 4px !important; padding: 4px !important; font-size: 12px !important; color: #333 !important; height: 30px !important; } #jp-hl-settings input[type="radio"] { margin-right: 5px !important; } #jp-hl-settings a { color: #189fd8 !important; text-decoration: none !important; } #jp-hl-settings a:hover { text-decoration: underline !important; } #jp-hl-settings #jp-hl-sync-status { margin-left: 4px !important; font-size: 11px !important; color: #666 !important; } #jp-hl-settings #jp-hl-domain-status { line-height: 28px !important; font-size: 12px !important; color: #666 !important; } /* 保证 Grid 布局不受干扰 */ #jp-hl-settings div[style*="display:grid"] { display: grid !important; } `); GM_registerMenuCommand("⚙️ 配置生词高亮", createSettingsPanel); window.addEventListener('load', () => { highlightText(document.body); observeDOM(); }); })();