Просьба от пользователя ЧатЯдра: в приложении на телефоне опечатки подчёркиваются, а в веб-версии нет. Два месяца задача лежала с пометкой «в вебе невозможно»: у Flutter на Android и iPhone проверку делает сама система, а в браузере такого канала нет вовсе. Пока не пришло в голову посмотреть на неё ещё раз.
Оказалось, что само подчёркивание Flutter рисует на своём холсте, без участия браузера, а
источник вердикта — интерфейс SpellCheckService, который можно подменить. Значит, нужен только
свой источник: сервер, который на список слов отвечает, какие из них неизвестны.
Сервер: hunspell через nspell
Словарь русского языка в формате hunspell уже лежал на сервере: тот же, что используется в игре «Словобой». Это 146 269 основ плюс правила образования форм. Плоского списка словоформ не бывает: «дома», «домах», «бежала» получаются применением правил, иначе список раздулся бы до миллионов. Движок — nspell, чистый JavaScript.
Замеры на нашем сервере: загрузка словаря 3,3 секунды однократно, 5000 проверок за 3 мс, память +171 МБ. Из этих цифр следуют три решения:
- Словарь грузится лениво, только если функцией реально пользуются.
- Первый запрос не ждёт загрузки: он получает ответ «ошибок нет», следующий уже точен. Лучше не подчеркнуть, чем подчеркнуть верное слово.
- Проверка дешёвая, а вот подсказки — нет, поэтому кэшируются отдельно.
Слова короче трёх букв не проверяются: предлоги, союзы, инициалы дают больше шума, чем пользы. Длиннее сорока — почти наверняка не слово. Кэш вердиктов на 50 000 записей, кэш подсказок на 10 000; при переполнении просто очищаются.
Почему встроенный подборщик не чинит «превед»
Подборщик nspell уверенно исправляет одну ошибку в слове: «саседи» → «соседи», «ошыбка» → «ошибка». Но пасует, когда ошибок две: на «превед» он предлагал «древе» и «краевед», а очевидного «привет» не находил. Там нужно поменять и «е→и», и «д→т».
Причина системная. В hunspell для типовых замен есть секция REP, и в русском словаре она пустая: проверено, ноль записей. Поэтому таблица своя, под ошибки, на которых спотыкаются именно по-русски:
const SWAPS = [
['о', 'а'], // карова → корова
['е', 'и'], // превед → привед…
['е', 'я'], ['и', 'ы'], ['э', 'е'],
['д', 'т'], // …привед → привет
['б', 'п'], ['г', 'к'], ['в', 'ф'], ['ж', 'ш'], ['з', 'с'], ['ш', 'щ'],
];
const CHUNK_SWAPS = [['тся', 'ться'], ['сс', 'с'], ['нн', 'н'], ['лл', 'л'], ['мм', 'м'], ['пп', 'п'], ['рр', 'р']];
Алгоритм простой: подставляем замены по таблице, сначала одну, при неудаче две, и каждого кандидата проверяем по словарю. Что прошло, то и предлагаем. Проверка слова занимает микросекунды, а двойные правки берутся комбинацией по той же маленькой таблице, поэтому перебор остаётся крошечным.
Встроенный подборщик вызывается только когда своих вариантов нет совсем, и не больше трёх раз за запрос: когда близких слов нет, он перебирает словарь и тратит сотни миллисекунд, а вставленный чужой текст даёт десятки незнакомых слов подряд.
API: слова, а не текст
POST /api/spellcheck принимает { words: [...], suggestions: true|false } и отвечает
{ unknown: [...], suggestions: {...}, ready: bool }. До 200 слов за раз: клиент шлёт только
то, чего нет в его кэше, так что пачки маленькие.
Принципиальный момент — приватность. На сервер уходят отдельные уникальные слова в случайном порядке, без знаков препинания. Восстановить из этого написанное нельзя. Слова с цифрами, адреса и телефоны клиент не отправляет вовсе, и сервер их тоже отсеивает. Ничего не хранится: ни слова, ни кто их прислал. Это зафиксировано в политике конфиденциальности, а в настройках есть выключатель.
Клиент: три правила
- Слово, которое человек печатает прямо сейчас, не проверяем. Иначе на «п», «пр», «при» ушло бы три запроса, и всё это подчеркнулось бы красным.
- Каждое слово спрашиваем один раз за всё время. Вердикты копятся в памяти и переживают перезапуск. Через пару дней обычного пользования почти всё отвечает локально. Лимит кэша 5000 слов.
- Одновременно летит один запрос. Пока он в пути, подчёркиваем по тому, что уже знаем.
Для нерусской локали сервис молчит: словарь у нас русский, и показывать чушь хуже, чем не показывать ничего.
Разбор текста в два шага
Самая важная деталь, и она про ту же приватность. Текст разбирается в два шага: сначала режется на куски по пробелам, и подозрительные куски отбрасываются целиком: с цифрами, собакой, слэшем, подчёркиванием или точкой не в конце. И только потом из оставшихся достаются слова.
Одним махом, регуляркой по буквам, делать нельзя. Из ivanov-77@mail.ru она вытащит «ivanov»
и «mail», из sntchat.ru — «sntchat», и эти обрывки уедут на сервер, хотя мы обещали, что
личные данные его не покидают.
bool _suspicious(String chunk) {
if (RegExp(r'[0-9@_/\\]').hasMatch(chunk)) return true;
final dot = chunk.indexOf('.');
return dot >= 0 && dot < chunk.length - 1; // домен, а не конец предложения
}
Точка в конце куска — обычный конец предложения, точка в середине — признак домена. Это поймал скрипт проверки разбора на 16 сценариях до выката, а не пользователь после.
Что осталось за кадром
Время ответа сервера с учётом сети специально не мерилось: серверная часть занимает
миллисекунды, а сеть у каждого своя. Лимит на клиента — 120 запросов за 10 минут, при
нормальном наборе текста до него не добраться. И отдельная история для деплоя: пакет nspell
был поставлен на сервере руками и десять дней отсутствовал в репозитории, пока дрейф не поймал
скрипт деплоя. Мораль старая: всё, что стоит на сервере, должно стоять и в package.json.