// ==UserScript== // @name 剑桥查词 // @namespace https://ld246.com/article/1760544378300 // @version 1.0.1 // @description 在任意网页选中英文后查询剑桥词典,支持发音、全球真人发音、钉住窗口和本地生词本。 // @author wish163 // @match http://*/* // @match https://*/* // @connect dictionary.cambridge.org // @connect dict.eudic.net // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_openInTab // @grant unsafeWindow // @run-at document-idle // @license MIT // @noframes // ==/UserScript== (() => { 'use strict'; const CONFIG = { autoRead: true, autoReadRegion: 'us', queryWhenPinned: true, maxWords: 3, showSponsor: false, // false 时不显示选词悬浮按钮,仍可通过 cambridgeDictionary.lookup() 查词 showSelectionButton: true, }; const CAMBRIDGE = 'https://dictionary.cambridge.org'; const EN_ZH_PATH = 'dictionary/english-chinese-simplified'; const EN_PATH = 'dictionary/english'; const WORDS_KEY = 'cambridge-browser-wordbook'; const STATE_KEY = 'cambridge-browser-state'; const FAVICON = `${CAMBRIDGE}/zhs/external/images/favicon.ico`; const speakerIcon = ` `; const globeIcon = ` `; const moonIcon = ``; const sunIcon = ``; let lastSelection = ''; let selectionRange = null; let pinned = false; let theme = window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; let requestSerial = 0; let currentAudio = null; let dragState = null; const host = document.createElement('div'); host.id = 'browser-cambridge-dictionary-host'; document.documentElement.appendChild(host); const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `
`; const $ = (selector, parent = root) => parent.querySelector(selector); const popup = $('.popup'); const body = $('.body'); const selectButton = $('.select-button'); const wordbook = $('.wordbook'); restoreState(); bindEvents(); registerMenu(); exposeApi(); function bindEvents() { document.addEventListener('mouseup', handleSelection, true); document.addEventListener('keyup', (event) => { if (event.key.startsWith('Arrow') || event.key === 'Shift') handleSelection(event); }, true); document.addEventListener('mousedown', (event) => { if (event.composedPath().includes(host)) return; if (!pinned && popup.style.display === 'flex') closePopup(); hideSelectButton(); }, true); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { $('.voices')?.classList.remove('show'); if (wordbook.classList.contains('show')) wordbook.classList.remove('show'); else closePopup(); } }, true); window.addEventListener('resize', () => { keepPopupInViewport(); positionOpenVoices(); }); selectButton.addEventListener('mousedown', (event) => event.preventDefault()); selectButton.addEventListener('click', () => lookup(lastSelection)); $('.close').addEventListener('click', closePopup); $('.theme').addEventListener('click', toggleTheme); $('.pin').addEventListener('click', togglePin); $('.book-link').addEventListener('click', showWordbook); $('.wordbook-close').addEventListener('click', () => wordbook.classList.remove('show')); wordbook.addEventListener('click', (event) => { if (event.target === wordbook) wordbook.classList.remove('show'); }); $('.export-words').addEventListener('click', exportWords); $('.clear-words').addEventListener('click', clearWords); $('.header').addEventListener('mousedown', startDrag); popup.addEventListener('mousedown', (event) => { const panel = $('.voices.show', body); if (panel && !panel.contains(event.target) && !event.target.closest('.global')) panel.classList.remove('show'); }); body.addEventListener('scroll', positionOpenVoices); document.addEventListener('mousemove', dragPopup, true); document.addEventListener('mouseup', stopDrag, true); } function handleSelection(event) { if (root.contains(event.target)) return; const selection = window.getSelection(); const text = normalizeQuery(selection?.toString() || ''); if (!isValidQuery(text) || selection.isCollapsed) { hideSelectButton(); return; } lastSelection = text; if (selection.rangeCount) selectionRange = selection.getRangeAt(0).cloneRange(); if (pinned && CONFIG.queryWhenPinned) { showPopup(); queryWord(text); return; } if (!CONFIG.showSelectionButton) { hideSelectButton(); return; } const rect = getSelectionRect(selection); if (!rect) return; const left = Math.min(window.innerWidth - 42, Math.max(8, rect.left + rect.width / 2 - 17)); const preferredTop = rect.top - 42; const top = preferredTop > 5 ? preferredTop : Math.min(window.innerHeight - 42, rect.bottom + 7); selectButton.style.left = `${left}px`; selectButton.style.top = `${top}px`; selectButton.style.display = 'block'; } function getSelectionRect(selection) { if (!selection.rangeCount) return null; const range = selection.getRangeAt(0); const rects = range.getClientRects(); return rects.length ? rects[rects.length - 1] : range.getBoundingClientRect(); } function normalizeQuery(value) { return value.replace(/\s+/g, ' ').replace(/^[\s“”‘’"'()[\]{}.,;:!?]+|[\s“”‘’"'()[\]{}.,;:!?]+$/g, '').trim(); } function isValidQuery(value) { if (!value || value.length > 80) return false; const words = value.trim().split(/\s+/); return words.length <= CONFIG.maxWords && words.every((word) => /^[a-z]+(?:['’-][a-z]+)*$/i.test(word)); } function lookup(rawKeyword) { const keyword = normalizeQuery(rawKeyword || window.getSelection()?.toString() || lastSelection); if (!isValidQuery(keyword)) { toast('仅支持 1 至 3 个英文单词'); return false; } lastSelection = keyword; hideSelectButton(); showPopup(); queryWord(keyword); return true; } function exposeApi() { const pageWindow = typeof unsafeWindow === 'undefined' ? window : unsafeWindow; pageWindow.cambridgeDictionary = Object.freeze({ lookup }); pageWindow.dispatchEvent(new pageWindow.CustomEvent('cambridge-dictionary-ready')); } function hideSelectButton() { selectButton.style.display = 'none'; } function showPopup() { popup.style.display = 'flex'; keepPopupInViewport(); } function closePopup() { popup.style.display = 'none'; stopAudio(); } function togglePin() { pinned = !pinned; $('.pin').classList.toggle('active', pinned); $('.pin').title = pinned ? '取消钉住' : '钉住窗口'; saveState(); } function toggleTheme() { theme = theme === 'dark' ? 'light' : 'dark'; applyTheme(); saveState(); } function applyTheme() { host.dataset.theme = theme; const button = $('.theme'); const label = theme === 'dark' ? '切换到亮色主题' : '切换到黑色主题'; button.title = label; button.setAttribute('aria-label', label); } function startDrag(event) { if (event.button !== 0 || event.target.closest('button')) return; const rect = popup.getBoundingClientRect(); dragState = { x: event.clientX - rect.left, y: event.clientY - rect.top }; event.preventDefault(); } function dragPopup(event) { if (!dragState || window.innerWidth <= 520) return; const left = Math.max(0, Math.min(window.innerWidth - popup.offsetWidth, event.clientX - dragState.x)); const top = Math.max(0, Math.min(window.innerHeight - 45, event.clientY - dragState.y)); popup.style.left = `${left}px`; popup.style.top = `${top}px`; positionOpenVoices(); } function stopDrag() { if (!dragState) return; dragState = null; saveState(); } function keepPopupInViewport() { if (popup.style.display !== 'flex' || window.innerWidth <= 520) return; const rect = popup.getBoundingClientRect(); popup.style.left = `${Math.max(0, Math.min(window.innerWidth - rect.width, rect.left))}px`; popup.style.top = `${Math.max(0, Math.min(window.innerHeight - 45, rect.top))}px`; } async function queryWord(rawKeyword) { const keyword = normalizeQuery(rawKeyword); if (!isValidQuery(keyword)) { renderError(keyword, '仅支持 1 至 3 个英文单词'); return; } lastSelection = keyword; const serial = ++requestSerial; renderLoading(keyword); let result; let path = EN_ZH_PATH; try { result = await fetchDictionary(keyword, path); if (!result) { path = EN_PATH; result = await fetchDictionary(keyword, path); } if (serial !== requestSerial) return; if (!result) { renderError(keyword, '剑桥词典中没有找到结果'); return; } renderResult(result, keyword, path); } catch (error) { if (serial !== requestSerial) return; console.error('[浏览器剑桥查词]', error); renderError(keyword, getRequestErrorMessage(error)); } } function getRequestErrorMessage(error) { const message = error?.message || ''; if (message === 'Request timeout') return '查询超时,请稍后重试'; if (message === 'Network error') return '无法连接剑桥词典,请检查网络'; const status = Number(message.match(/^HTTP (\d+)$/)?.[1]); if (status === 403) return '剑桥词典拒绝了本次请求'; if (status === 404) return '查询页面未找到'; if (status === 429) return '查询过于频繁,请稍后重试'; if (status >= 500) return '剑桥词典服务暂时异常,请稍后重试'; return '查询失败,请稍后重试'; } function renderLoading(keyword) { $('.more').href = `${CAMBRIDGE}/search/direct/?datasetsearch=english-chinese-simplified&q=${encodeURIComponent(keyword)}`; body.innerHTML = `
正在查询 “${escapeHtml(keyword)}”
`; } function renderError(keyword, message) { body.innerHTML = `
${escapeHtml(message)}${escapeHtml(keyword)}
`; $('.retry', body).addEventListener('click', () => queryWord(keyword)); $('.bing', body).addEventListener('click', () => openTab(`https://www.bing.com/search?q=${encodeURIComponent(keyword)}`)); $('.ai', body).addEventListener('click', () => openTab(`https://chat.baidu.com/search?word=${encodeURIComponent(`请查询“${keyword}”,注明音标、发音、常见释义和例句。`)}`)); } function renderResult(result, originalKeyword, path) { body.innerHTML = ''; const wordRow = createElement('div', 'word-row'); const input = createElement('input', 'search-input'); input.type = 'text'; input.value = result.word; input.setAttribute('aria-label', '输入单词查词'); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') queryWord(input.value); }); wordRow.appendChild(input); const actions = createElement('div', 'actions'); const saveButton = makeAction('☆', '添加到生词本(右键添加备注)'); updateSaveButton(saveButton, result.word); saveButton.addEventListener('click', () => toggleSavedWord(result.word, '', saveButton)); saveButton.addEventListener('contextmenu', (event) => { event.preventDefault(); const old = getWords().find((item) => item.word.toLowerCase() === result.word.toLowerCase()); const note = window.prompt(`为 ${result.word} 添加备注:`, old?.note || ''); if (note !== null) toggleSavedWord(result.word, note.trim(), saveButton, true); }); actions.appendChild(saveButton); actions.appendChild(makeImageAction('https://www.bing.com/favicon.ico', 'Bing 查询', () => openTab(`https://www.bing.com/search?q=${encodeURIComponent(result.word)}`))); actions.appendChild(makeImageAction('https://gips0.baidu.com/it/u=1125504705,2263448440&fm=3028&app=3028&f=PNG&fmt=auto&q=75&size=f16_16', '问 AI', () => openTab(`https://chat.baidu.com/search?word=${encodeURIComponent(`请查询“${result.word}”,注明音标、发音、常见释义和例句。`)}`))); wordRow.appendChild(actions); body.appendChild(wordRow); if (result.posgram) appendTextElement(body, 'div', 'posgram', result.posgram); const phonetics = createElement('div', 'phonetics'); let autoReadUrl = ''; for (const item of result.phonetics) { if (!item.ipa) continue; const row = createElement('div', 'phonetic'); appendTextElement(row, 'span', '', item.region === 'us' ? '美' : '英'); appendTextElement(row, 'span', '', `[${item.ipa}]${item.ipaWeak ? ` weak [${item.ipaWeak}]` : ''}`); const audioButton = createElement('button', 'audio'); audioButton.type = 'button'; audioButton.title = `${item.region === 'us' ? '美式' : '英式'}发音`; audioButton.innerHTML = speakerIcon; const audioUrl = absoluteUrl(item.audio, CAMBRIDGE); audioButton.disabled = !audioUrl; audioButton.addEventListener('click', () => playAudio(audioUrl)); row.appendChild(audioButton); phonetics.appendChild(row); if (CONFIG.autoRead && CONFIG.autoReadRegion === item.region) autoReadUrl = audioUrl; } const globalButton = createElement('button', 'global'); globalButton.type = 'button'; globalButton.title = '全球真人发音'; globalButton.innerHTML = globeIcon; globalButton.addEventListener('click', () => toggleGlobalVoices(result.word, originalKeyword, globalButton)); phonetics.appendChild(globalButton); body.appendChild(phonetics); const voices = createElement('div', 'voices'); body.appendChild(voices); if (result.irregular) appendTextElement(body, 'div', 'irregular', result.irregular); for (const part of result.parts) { const summary = createElement('div', 'summary'); appendTextElement(summary, 'span', 'summary-part', part.part); summary.appendChild(document.createTextNode(part.means.join('; '))); body.appendChild(summary); } const details = createElement('div', 'details'); for (const addition of result.additions) { appendTextElement(details, 'div', 'detail-title', addition.part); if (addition.part.startsWith('例句')) { const example = createElement('div', 'example'); const [english = '', chinese = ''] = addition.means[0].split('\n'); appendTextElement(example, 'div', 'example-en', english); appendTextElement(example, 'div', 'example-zh', chinese); details.appendChild(example); } else { const definition = createElement('div', 'definition'); appendMarkedLevel(definition, addition.means[0] || ''); details.appendChild(definition); } } body.appendChild(details); $('.more').href = `${CAMBRIDGE}/${path}/${encodeURIComponent(result.word.toLowerCase().replaceAll(' ', '-'))}`; if (autoReadUrl) playAudio(autoReadUrl, true); } async function toggleGlobalVoices(word, originalKeyword, button) { const panel = $('.voices', body); if (!panel) return; if (panel.classList.contains('show')) { panel.classList.remove('show'); return; } panel.classList.add('show'); panel.innerHTML = '
正在获取全球发音…
'; positionGlobalVoices(panel, button); try { let voices = await fetchGlobalVoices(word.toLowerCase()); if (!voices.length && word.toLowerCase() !== originalKeyword.toLowerCase()) voices = await fetchGlobalVoices(originalKeyword.toLowerCase()); panel.innerHTML = ''; const header = createElement('div', 'voices-head'); appendTextElement(header, 'span', '', `有 ${voices.length} 个发音`); const close = createElement('button', 'voices-close'); close.type = 'button'; close.textContent = '×'; close.addEventListener('click', () => panel.classList.remove('show')); header.appendChild(close); panel.appendChild(header); const list = createElement('div', 'voices-list'); if (!voices.length) appendTextElement(list, 'div', 'empty', '暂无全球发音'); for (const voice of voices) { const item = createElement('button', 'voice'); item.type = 'button'; item.innerHTML = `${speakerIcon}`; const info = createElement('span', 'voice-info'); appendTextElement(info, 'span', '', voice.gender); appendTextElement(info, 'span', '', voice.country); item.appendChild(info); item.addEventListener('click', () => playAudio(voice.audio)); list.appendChild(item); } const more = createElement('button', 'voice'); more.type = 'button'; more.textContent = '更多发音 >>'; more.addEventListener('click', () => openTab(`https://zh.forvo.com/search/${encodeURIComponent(word)}/en_usa/`)); list.appendChild(more); panel.appendChild(list); positionGlobalVoices(panel, button); } catch (error) { panel.innerHTML = '
全球发音获取失败
'; positionGlobalVoices(panel, button); } } function positionOpenVoices() { const panel = $('.voices.show', body); const button = $('.global', body); if (panel && button) positionGlobalVoices(panel, button); } function positionGlobalVoices(panel, button) { if (!panel.classList.contains('show')) return; const gap = 6; const edge = 8; const buttonRect = button.getBoundingClientRect(); const bodyRect = body.getBoundingClientRect(); const panelWidth = panel.offsetWidth; const left = Math.max(edge, Math.min(window.innerWidth - panelWidth - edge, bodyRect.right - panelWidth)); const availableBelow = window.innerHeight - buttonRect.bottom - gap - edge; const availableAbove = buttonRect.top - gap - edge; const showAbove = availableBelow < Math.min(panel.offsetHeight, 270) && availableAbove > availableBelow; const available = Math.max(90, showAbove ? availableAbove : availableBelow); const list = $('.voices-list', panel); if (list) list.style.maxHeight = `${Math.max(45, Math.min(220, available - $('.voices-head', panel).offsetHeight))}px`; const top = showAbove ? buttonRect.top - panel.offsetHeight - gap : buttonRect.bottom + gap; panel.style.left = `${left}px`; panel.style.top = `${Math.max(edge, Math.min(window.innerHeight - panel.offsetHeight - edge, top))}px`; } async function fetchDictionary(keyword, path) { const slug = encodeURIComponent(keyword.toLowerCase().replaceAll(' ', '-')); const html = await requestText(`${CAMBRIDGE}/${path}/${slug}`); return parseDictionary(html); } function parseDictionary(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const firstEntry = doc.querySelector('.entry-body__el'); const word = text(firstEntry?.querySelector('.headword')) || text(doc.querySelector('.headword')); if (!word) return null; const posgram = text(firstEntry?.querySelector('.posgram')); const irregular = text(firstEntry?.querySelector('.pos-header .irreg-infls')).replace(/^\s*-|\s*-$/g, '').trim(); const usBlock = firstEntry?.querySelector('.us') || doc.querySelector('.us'); const ukBlock = firstEntry?.querySelector('.uk') || doc.querySelector('.uk'); const usWeak = weakIpaAfter(usBlock, 'us'); const ukWeak = weakIpaAfter(ukBlock, 'uk'); const phonetics = [ makePhonetic(usBlock, 'us', usWeak), makePhonetic(ukBlock, 'uk', ukWeak), ]; const additions = []; const partMap = new Map(); const entries = [...doc.querySelectorAll('.entry-body__el')]; const explanationCount = entries.length; for (const entry of entries) { const part = text(entry.querySelector('.posgram')) || text(entry.querySelector('.anc-info-head')) || 'unknown'; for (const definition of entry.querySelectorAll('.def-block')) { const english = text(definition.querySelector('.ddef_h')); const definitionBody = definition.querySelector('.ddef_b'); const chinese = text(definitionBody?.querySelector('.trans')) || text(definitionBody?.firstElementChild); pushAddition(additions, `${part}-英文释义`, english); pushAddition(additions, `${part}-中文释义`, chinese); if (chinese) { if (!partMap.has(part)) partMap.set(part, []); if (!partMap.get(part).includes(chinese)) partMap.get(part).push(chinese); } let exampleIndex = 0; for (const example of definition.querySelectorAll('.examp')) { const englishExample = text(example.querySelector('.eg')); const chineseExample = text(example.querySelector('.trans')) || text(example.querySelector('.eg')?.nextElementSibling); if ((englishExample || chineseExample) && (explanationCount <= 1 || exampleIndex === 0)) { pushAddition(additions, `例句${exampleIndex + 1}`, `${englishExample}\n${chineseExample}`); } exampleIndex += 1; } } } return { word, posgram, irregular, phonetics, parts: [...partMap].map(([part, means]) => ({ part, means })), additions, }; } function weakIpaAfter(block, ownRegion) { let sibling = block?.nextElementSibling; while (sibling && !sibling.matches('.pron-info, .posgram, .def-block, .dsense')) { if (sibling.matches(`.${ownRegion}`)) return text(sibling.querySelector('.ipa')); if (sibling.matches('.us, .uk')) break; const ipa = text(sibling.querySelector?.('.ipa')); if (ipa) return ipa; sibling = sibling.nextElementSibling; } return ''; } function makePhonetic(block, region, ipaWeak) { return { region, ipa: text(block?.querySelector('.pron .ipa')) || text(block?.querySelector('.ipa')), ipaWeak, audio: block?.querySelector('source[type="audio/mpeg"]')?.getAttribute('src') || '', }; } function pushAddition(target, part, value) { const clean = value.trim(); if (clean) target.push({ part, means: [clean] }); } async function fetchGlobalVoices(keyword) { const html = await requestText(`https://dict.eudic.net/dicts/en/${encodeURIComponent(keyword)}`); const doc = new DOMParser().parseFromString(html, 'text/html'); return [...doc.querySelectorAll('.gv_details .gv_item')].map((item) => ({ audio: absoluteUrl(item.querySelector('.gv-voice')?.getAttribute('data-rel')?.trim() || '', 'https://dict.eudic.net'), gender: text(item.querySelector('.gv_person')), country: text(item.querySelector('.gv_contury')), })).filter((voice) => voice.audio); } function requestText(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, redirect: 'manual', timeout: 15000, headers: { Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'User-Agent': navigator.userAgent }, onload: (response) => { const dictionaryHome = `${url.slice(0, url.lastIndexOf('/') + 1)}`; if (response.finalUrl?.split('?')[0] === dictionaryHome) { resolve(''); return; } if (response.status >= 200 && response.status < 400) resolve(response.responseText); else reject(new Error(`HTTP ${response.status}`)); }, onerror: () => reject(new Error('Network error')), ontimeout: () => reject(new Error('Request timeout')), }); }); } function playAudio(url, quiet = false) { if (!url) { if (!quiet) toast('没有可用的发音'); return; } stopAudio(); currentAudio = new Audio(url); currentAudio.play().catch(() => { if (!quiet) toast('浏览器阻止了音频播放,请再点一次'); }); } function stopAudio() { if (!currentAudio) return; currentAudio.pause(); currentAudio = null; } function getWords() { const words = GM_getValue(WORDS_KEY, []); return Array.isArray(words) ? words : []; } function toggleSavedWord(word, note, button, forceSave = false) { const words = getWords(); const index = words.findIndex((item) => item.word.toLowerCase() === word.toLowerCase()); if (index >= 0 && !forceSave) { words.splice(index, 1); GM_setValue(WORDS_KEY, words); updateSaveButton(button, word); toast('已从生词本移除'); return; } const item = { word, note, addedAt: new Date().toISOString() }; if (index >= 0) words[index] = { ...words[index], ...item }; else words.unshift(item); GM_setValue(WORDS_KEY, words); updateSaveButton(button, word); toast(index >= 0 ? '备注已更新' : '已添加到生词本'); } function updateSaveButton(button, word) { const saved = getWords().some((item) => item.word.toLowerCase() === word.toLowerCase()); button.textContent = saved ? '★' : '☆'; button.classList.toggle('saved', saved); } function showWordbook() { renderWordbook(); wordbook.classList.add('show'); } function renderWordbook() { const list = $('.word-list'); const words = getWords(); list.innerHTML = ''; if (!words.length) { const empty = createElement('li', 'empty'); empty.textContent = '生词本还是空的'; list.appendChild(empty); return; } words.forEach((item, index) => { const row = document.createElement('li'); const main = createElement('span', 'word-main'); main.textContent = item.word; main.title = '点击查词'; main.addEventListener('click', () => { wordbook.classList.remove('show'); showPopup(); queryWord(item.word); }); row.appendChild(main); appendTextElement(row, 'span', 'word-note', item.note || new Date(item.addedAt).toLocaleDateString()); const remove = createElement('button', 'word-delete'); remove.type = 'button'; remove.title = '删除'; remove.textContent = '×'; remove.addEventListener('click', () => { const next = getWords(); next.splice(index, 1); GM_setValue(WORDS_KEY, next); renderWordbook(); }); row.appendChild(remove); list.appendChild(row); }); } function exportWords() { const words = getWords(); if (!words.length) return toast('生词本还是空的'); const csv = '\uFEFF单词,备注,添加时间\n' + words.map((item) => [item.word, item.note, item.addedAt].map(csvCell).join(',')).join('\n'); const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = `剑桥生词本-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function clearWords() { if (!getWords().length || !window.confirm('确定清空全部生词吗?')) return; GM_setValue(WORDS_KEY, []); renderWordbook(); const input = $('.search-input', body); if (input) updateSaveButton($('.icon-action', body), input.value); } function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('打开剑桥查词', () => { const keyword = window.prompt('输入要查询的英文单词或短语:', lastSelection); if (keyword) { showPopup(); queryWord(keyword); } }); GM_registerMenuCommand('打开生词本', showWordbook); } function restoreState() { const state = GM_getValue(STATE_KEY, {}); if (state && typeof state === 'object') { if (Number.isFinite(state.left)) popup.style.left = `${state.left}px`; if (Number.isFinite(state.top)) popup.style.top = `${state.top}px`; if (state.theme === 'dark' || state.theme === 'light') theme = state.theme; pinned = Boolean(state.pinned); } $('.pin').classList.toggle('active', pinned); applyTheme(); } function saveState() { const rect = popup.getBoundingClientRect(); GM_setValue(STATE_KEY, { left: rect.left, top: rect.top, pinned, theme }); } function makeAction(label, title) { const button = createElement('button', 'icon-action'); button.type = 'button'; button.title = title; button.textContent = label; return button; } function makeImageAction(src, title, handler) { const button = makeAction('', title); const image = document.createElement('img'); image.src = src; image.alt = ''; button.appendChild(image); button.addEventListener('click', handler); return button; } function createElement(tag, className) { const element = document.createElement(tag); if (className) element.className = className; return element; } function appendTextElement(parent, tag, className, value) { const element = createElement(tag, className); element.textContent = value; parent.appendChild(element); return element; } function appendMarkedLevel(parent, value) { const match = value.match(/^(A1|A2|B1|B2|C1|C2)\b/); if (!match) { parent.textContent = value; return; } appendTextElement(parent, 'span', 'level', match[1]); parent.appendChild(document.createTextNode(value.slice(match[1].length))); } function toast(message) { const element = $('.toast'); element.textContent = message; element.style.display = 'block'; clearTimeout(toast.timer); toast.timer = setTimeout(() => { element.style.display = 'none'; }, 2200); } function openTab(url) { if (typeof GM_openInTab === 'function') GM_openInTab(url, { active: true, insert: true }); else window.open(url, '_blank', 'noopener'); } function text(element) { return element?.textContent?.replace(/\s+/g, ' ').trim() || ''; } function absoluteUrl(url, base) { if (!url) return ''; try { return new URL(url, base).href; } catch { return ''; } } function escapeHtml(value) { return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); } function csvCell(value) { return `"${String(value || '').replaceAll('"', '""')}"`; } })();