Проверка орфографии в PWA на Flutter: hunspell на сервере, своя таблица опечаток и разбор текста в два шага

В вебе у Flutter нет системной проверки текста. Оказалось, подчёркивание он рисует сам, а источник вердикта подключаемый. Как сделан сервис на nspell с русским словарём: замеры, кэши, почему встроенный подборщик не чинит «превед» и что нельзя отправлять на сервер.

Просьба от пользователя ЧатЯдра: в приложении на телефоне опечатки подчёркиваются, а в веб-версии нет. Два месяца задача лежала с пометкой «в вебе невозможно»: у Flutter на Android и iPhone проверку делает сама система, а в браузере такого канала нет вовсе. Пока не пришло в голову посмотреть на неё ещё раз.

Оказалось, что само подчёркивание Flutter рисует на своём холсте, без участия браузера, а источник вердикта — интерфейс SpellCheckService, который можно подменить. Значит, нужен только свой источник: сервер, который на список слов отвечает, какие из них неизвестны.

Сервер: hunspell через nspell

Словарь русского языка в формате hunspell уже лежал на сервере: тот же, что используется в игре «Словобой». Это 146 269 основ плюс правила образования форм. Плоского списка словоформ не бывает: «дома», «домах», «бежала» получаются применением правил, иначе список раздулся бы до миллионов. Движок — nspell, чистый JavaScript.

Замеры на нашем сервере: загрузка словаря 3,3 секунды однократно, 5000 проверок за 3 мс, память +171 МБ. Из этих цифр следуют три решения:

  1. Словарь грузится лениво, только если функцией реально пользуются.
  2. Первый запрос не ждёт загрузки: он получает ответ «ошибок нет», следующий уже точен. Лучше не подчеркнуть, чем подчеркнуть верное слово.
  3. Проверка дешёвая, а вот подсказки — нет, поэтому кэшируются отдельно.

Слова короче трёх букв не проверяются: предлоги, союзы, инициалы дают больше шума, чем пользы. Длиннее сорока — почти наверняка не слово. Кэш вердиктов на 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 слов за раз: клиент шлёт только то, чего нет в его кэше, так что пачки маленькие.

Принципиальный момент — приватность. На сервер уходят отдельные уникальные слова в случайном порядке, без знаков препинания. Восстановить из этого написанное нельзя. Слова с цифрами, адреса и телефоны клиент не отправляет вовсе, и сервер их тоже отсеивает. Ничего не хранится: ни слова, ни кто их прислал. Это зафиксировано в политике конфиденциальности, а в настройках есть выключатель.

Клиент: три правила

  1. Слово, которое человек печатает прямо сейчас, не проверяем. Иначе на «п», «пр», «при» ушло бы три запроса, и всё это подчеркнулось бы красным.
  2. Каждое слово спрашиваем один раз за всё время. Вердикты копятся в памяти и переживают перезапуск. Через пару дней обычного пользования почти всё отвечает локально. Лимит кэша 5000 слов.
  3. Одновременно летит один запрос. Пока он в пути, подчёркиваем по тому, что уже знаем.

Для нерусской локали сервис молчит: словарь у нас русский, и показывать чушь хуже, чем не показывать ничего.

Разбор текста в два шага

Самая важная деталь, и она про ту же приватность. Текст разбирается в два шага: сначала режется на куски по пробелам, и подозрительные куски отбрасываются целиком: с цифрами, собакой, слэшем, подчёркиванием или точкой не в конце. И только потом из оставшихся достаются слова.

Одним махом, регуляркой по буквам, делать нельзя. Из 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.