// ==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('"', '""')}"`;
}
})();