Было популярное китайское расширение Dadda Translate (达达划词翻译): выделяешь слово, получаешь перевод, сохраняешь в словарик, и слово потом всплывает уведомлением, пока не запомнится. Оно перестало работать: API Sogou и Shanbay, на которых держался перевод, изменились или исчезли. К тому же оно было только для Chrome и только для пары английский → китайский.
Я переписал его с нуля. Получился WordToast для Firefox, Chrome и Edge (GitHub). Он переводит на язык браузера, умеет работать полностью на устройстве и объяснять слово в контексте через ваш собственный ИИ-ключ. В статье расскажу о трёх технических частях:
-
встроенный в Chrome Translator API: полезный, но с неочевидными ограничениями;
-
вызов LLM прямо из расширения, без своего сервера;
-
всплывающие карточки поверх любого сайта, которые не ломаются от чужого CSS и CSP.
Несколько движков и откат
Движков перевода пять:
-
Google (публичный эндпоинт
translate.googleapis.com); -
MyMemory;
-
DeepL с вашим ключом;
-
встроенный переводчик Chrome;
-
любая LLM.
Движки выстроены в цепочку: если первый вернул ошибку, лимит или пустой ответ, берётся следующий. Для одиночных английских слов карточку дополняет словарь dictionaryapi.dev: части речи, определения, примеры, произношение.
Пара практических наблюдений:
-
У бесплатных API бывают долгие ответы. dictionaryapi.dev иногда отвечает по 10 секунд или отдаёт 5xx. Поэтому у каждого запроса свой таймаут: карточка сначала показывает перевод, а словарная часть догружается следом.
-
У DeepL два адреса. Бесплатный ключ определяется по суффиксу
:fxи ходит наapi-free.deepl.com, платный — наapi.deepl.com. Об этом легко забыть и получить загадочный 403. -
Оригинал делал опасную вещь. Dadda скачивал скрипт с сайта переводчика и выполнял его через
eval, чтобы получить токен. В MV3 такое запрещено, да и раньше это было плохой идеей. В WordToast никакого внешнего кода нет.
Перевод на устройстве: Translator API в Chrome
Начиная с Chrome 138 в браузере есть встроенные модели перевода и определения языка: Translator и LanguageDetector. Текст никуда не уходит, всё считается локально. Для расширения, которое видит всё, что вы выделяете на страницах, это сильный аргумент в пользу приватности.
const g = globalThis as any;
async function onDeviceTranslate(text: string, to: string) {
const [best] = await (await g.LanguageDetector.create()).detect(text);
if (!best || best.confidence < 0.3 || best.detectedLanguage === "und") throw new Error("language not detected");
const from = best.detectedLanguage.split("-")[0];
const availability = await g.Translator.availability({ sourceLanguage: from, targetLanguage: to });
if (availability === "unavailable") throw new Error(`${from}→${to} unsupported`);
const translator = await g.Translator.create({ sourceLanguage: from, targetLanguage: to });
return translator.translate(text);
}
Подводные камни:
-
Не работает в service worker. API доступен только в оконных контекстах, а фон MV3-расширения — это как раз service worker. Поэтому перевод на устройстве вызывается из content script, а не из фона, где живут остальные движки.
-
Первая загрузка модели требует жеста пользователя. Модель языковой пары скачивается при первом использовании, и
create()без клика пользователя может упасть. Поэтому объекты переводчиков кешируются по паре языков, а ошибку создания мы не кешируем, чтобы при следующем клике попробовать снова. -
Короткому тексту детектор часто не уверен. На одиночном слове уверенность бывает низкой. Ниже порога 0.3 мы честно откатываемся на следующий движок, а не угадываем язык.
-
В Firefox такого API нет. Там этот движок просто скрыт в настройках.
LLM из расширения, без своего сервера
Кнопка «Объяснить с ИИ» даёт значение слова в этом контексте: мы передаём модели предложение, в котором слово встретилось. Плюс примеры и заметки об употреблении.
Можно подключить OpenAI, Claude, Gemini или любой OpenAI-совместимый эндпоинт — OpenRouter или локальную Ollama.
Своего сервера нет: запрос уходит из расширения напрямую провайдеру с ключом пользователя. SDK я сознательно не брал: три SDK раздули бы расширение, а нужен один короткий запрос. Хватает одной функции, которая строит запрос под провайдера.
Нюанс с Claude: Anthropic API из браузерного контекста отвечает ошибкой CORS, пока вы явно не подтвердите, что понимаете риск держать ключ на клиенте:
headers: {
"content-type": "application/json",
"x-api-key": cfg.apiKey,
"anthropic-version": "2023-06-01",
// Обязателен для вызовов из браузерного контекста
"anthropic-dangerous-direct-browser-access": "true",
}
Здесь риск осознанный: это ключ самого пользователя, он хранится в storage.local его браузера и уходит только провайдеру.
Для локальной Ollama отдельно нужен доступ к http://localhost:11434. Именно поэтому провайдер «custom» принимает произвольный base URL.
Карточка поверх любого сайта
Карточка перевода и всплывающие слова для повторения рисуются прямо на странице. Значит, они живут в чужом DOM, под чужим CSS и чужой CSP. Решение такое:
-
Собственный элемент
<wordtoast-root>и shadow root. Стили страницы не пролезают внутрь, а наши не вылезают наружу. На корневой элемент ставитсяall: initialи максимальныйz-index. -
Стили через
adoptedStyleSheets. Страница с жёсткой CSP (style-srcбезunsafe-inline) заблокирует вставленный<style>. Сконструированные таблицы стилей под эту директиву не попадают:
const root = host.attachShadow({ mode: "open" });
try {
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
root.adoptedStyleSheets = [sheet]; // не блокируется style-src страницы
} catch {
const style = document.createElement("style"); // запасной вариант
style.textContent = css;
root.appendChild(style);
}
-
Координаты от выделения. Позиция берётся из
getBoundingClientRect()выделения, у полей ввода — из самого поля. Карточка переворачивается вверх, если снизу не помещается.
Интервальные повторения
Сохранённое слово запоминается вместе с предложением, где вы его нашли, и ссылкой на страницу. Потом оно всплывает небольшой карточкой в углу страницы с кнопками «Помню» и «Забыл». Интервалы растут:
export const STAGE_DELAYS = [5 * MIN, 30 * MIN, 12 * HOUR, DAY, 3 * DAY, 7 * DAY, 21 * DAY, 60 * DAY];
Первые два шага (5 и 30 минут) взяты у Dadda, дальше классическая схема с растущими интервалами. «Помню» переводит слово на следующую ступень, «Забыл» начинает заново. Если карточку проигнорировали, она вернётся через 20 минут.
Особенность MV3: у фонового service worker нет долгоживущих таймеров. Раз в минуту срабатывает chrome.alarms, фон ищет слово, которое пора повторить, и отправляет его в content script активной вкладки. Всё состояние хранится в storage, поэтому worker может спокойно засыпать между проверками.
Словарь экспортируется в Anki (TSV: лицевая сторона, оборот, теги), CSV и JSON.
Мелочь, которая съела вечер: горячие клавиши
Я хотел горячие клавиши Alt+Shift+W и Alt+Shift+T. Chrome молча не назначает сочетания, которые считает зарезервированными или занятыми: ни ошибки в консоли, ни предупреждения, команда просто остаётся без клавиши.
Проверить это можно только через chrome.commands.getAll(). Теперь это делает E2E-тест, а страница настроек показывает фактически назначенные сочетания со ссылкой на chrome://extensions/shortcuts.
Итог
-
Выделил → перевод на язык браузера. Любая пара языков, откат между движками.
-
Перевод на устройстве в Chrome, свой ключ для DeepL и LLM, «объяснить в контексте».
-
Словарь с контекстом, всплывающие повторения, экспорт в Anki.
-
Firefox, Chrome, Edge, одна кодовая база на MV3. Без аккаунтов и аналитики, MIT (как у оригинала).
Код и релизы: https://github.com/Perruer/wordtoast
Если пригодится, поддержать разработку можно на Boosty.
Автор: Perruer


