Недавно я перечитывал свою старую статью про SSP и наткнулся на фразу: рекламный инвентарь — это «месть для рекламы». Разумеется, я имел в виду «место для рекламы». Опечатка пережила мою вычитку, спокойно проехала публикацию и месяцами жила в проде. Тогда я решил, что так больше нельзя: проверку правописания пора автоматизировать. В этой статье расскажу, как я встроил в свой Hugo-блог автоматическую проверку орфографии и пунктуации и как теперь постепенно вычищаю накопленные ошибки в старых материалах.
Почему вычитки глазами недостаточно
Своя опечатка — самая незаметная. Глаз привыкает, мозг достраивает знакомый текст, и «месть» читается как «место». Чем больше постов и чем чаще пишешь, тем выше шанс, что одна из них доедет до читателя.
Мне хотелось не разовой чистки, а постоянной гарантии: новый пост не уезжает в прод с глупой ошибкой. При этом важно было не загнать себя в угол — у меня уже накоплен бэклог из десятков старых статей, и блокировать публикацию из-за ошибки трёхлетней давности в каком-то другом посте было бы абсурдно.
Каким я хотел видеть решение
Прежде чем писать код, я сформулировал требования:
- Русский язык: орфография, пунктуация и базовая грамматика для
ru-RU. - Локально: тексты не уходят во внешние сервисы — это и приватность, и отсутствие лимитов.
- Понимает markdown: не ругается на код, YAML-фронтматтер, шорткоды Hugo, ссылки и URL.
- Встроено в пайплайн: проверка блокирует
make deploy. - Только изменённое: на деплое проверяются лишь новые и изменённые посты, чтобы старый бэклог не мешал.
- Управляемые ложные срабатывания: технические термины и жаргон не должны топить отчёт.
Движок: LanguageTool локально
В основе — LanguageTool, опенсорсный движок проверки текста с приличной поддержкой русского. Я не дёргаю публичный API, а поднимаю локальный languagetool-http-server, прогоняю через него все файлы и гашу сервер. Один подъём на весь прогон — ради скорости: старт сервера дороже самой проверки.
Вокруг него — небольшая Go-обёртка tools/proofreader/. Её задача не «проверять текст» (это делает LanguageTool), а подготовить markdown к проверке и отфильтровать ложные срабатывания.
Подготовка текста: оставляем только прозу
LanguageTool ничего не знает про markdown. Если скормить ему пост целиком, он утонет в ругани на код, идентификаторы, URL и шорткоды. Поэтому перед проверкой я вырезаю всё, что не является прозой: фронтматтер, fenced-блоки кода, инлайн-код, шорткоды Hugo, картинки и URL в ссылках.
Тонкость в том, что вырезать нужно, не сдвигая координаты, — иначе номера строк в отчёте перестанут совпадать с файлом. Поэтому каждая замена сохраняет число строк и рунную длину: блоки кода превращаются в пустые строки, а инлайн-конструкции — в заглушки той же длины.
// cleanInline вырезает inline-конструкции, сохраняя рунную длину строки.
func cleanInline(line string) string {
line = shortcodeRe.ReplaceAllStringFunc(line, placeholder)
line = inlineRe.ReplaceAllStringFunc(line, placeholder)
line = imageRe.ReplaceAllStringFunc(line, placeholder)
line = linkRe.ReplaceAllStringFunc(line, blankLinkKeepText)
line = urlRe.ReplaceAllStringFunc(line, placeholder)
return line
}
func placeholder(s string) string {
n := utf8.RuneCountInString(s)
if n <= 1 {
return "X"
}
return "X" + strings.Repeat("x", n-1) // "Xxxx" той же длины
}
Заглушка — это Xxxx, а не пробелы. Если заменить код пробелами, примыкающая к нему запятая превратится в «пробел перед запятой» и будет помечена как ложная ошибка. А сами заглушки безвредны: в них нет кириллицы, и фильтр орфографии их отсеивает (об этом ниже). Для ссылок вида [текст](url) я сохраняю видимый текст — его тоже нужно проверять, — а скобки и URL заменяю пробелами.
Борьба с ложными срабатываниями
Даже на чистой прозе LanguageTool щедр на ложные срабатывания: жаргон, заимствования, имена. «Логгер», «горутина», «бэкенд», «паблишер» он считает опечатками. Здесь у меня две линии обороны.
Первая — токены без кириллицы. Латиница и идентификаторы (slog, zerolog, goroutine) почти всегда легитимны в русском тексте, поэтому отсеиваются автоматически, без всякого словаря.
Вторая — словарь исключений dictionary.txt для кириллических терминов. Он поддерживает основы: запись со звёздочкой логгер* закрывает все падежные формы (логгер, логгера, логгерами), чтобы не перечислять их вручную.
func isFalsePositive(m ltMatch, flagged string, dict *dictionary) bool {
word := strings.ToLower(strings.TrimSpace(flagged))
switch m.Rule.ID {
case spellRuleID: // MORFOLOGIK_RULE_RU_RU
// Термин из словаря или токен без кириллицы — не опечатка.
return dict.contains(word) || !hasCyrillic(word)
}
return false
}
Сам словарь читается человеком и выглядит так:
# жаргон и заимствования (основами, чтобы покрыть падежи)
логгер*
горутин*
бэкенд*
паблишер*
программатик*
# точные формы
прод
ключ-значение
Словарь гасит только орфографию. Пунктуацию и капитализацию не трогает — и правильно: иначе легко заглушить настоящую ошибку.
Встраивание в пайплайн
Дальше — две цели в Makefile. Основная, make proofread, проверяет только посты, изменённые с момента последнего релизного тега:
proofread: tools/proofreader/proofreader
@LATEST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null); \
CHANGED=$$(git diff --name-only "$$LATEST_TAG" -- 'content/**/*.md' ...); \
tools/proofreader/proofreader $$CHANGED
Она вшита в деплой как блокер: clean → build → custom-lint → proofread → tag-release → sync. Если в новом посте есть ошибка — деплой не пройдёт. А для ручного полного прогона по всему content/ есть make proofread-all.
В обычном прогоне это выглядит так:
$ make proofread
proofread: проверяю изменённые посты:
content/blog/ssp-adtech.md
content/blog/ssp-adtech.md:11:114 [MORFOLOGIK_RULE_RU_RU] Возможно найдена орфографическая ошибка.
...рекламного инвентаря (месть для рекламы) посредством аукционов...
→ местью, мест, месте
Вот она, та самая «месть» — теперь её ловит машина, а не случайный взгляд спустя полгода.
Как я чищу старые статьи
Я сознательно не стал чинить весь архив одним героическим заходом. Стратегия «проверяем только изменённое» работает мягко: каждый раз, когда я касаюсь старого поста, он проходит проверку, и я заодно вычищаю его. Плюс время от времени запускаю make proofread-all и разбираю одну порцию замечаний.
При разборе быстро выясняется, что часть «ошибок» — это придирки движка, и реагировать на них нужно по-разному:
| Тип срабатывания | Пример | Что делаю |
|---|---|---|
| Настоящая опечатка | месть вместо место | Исправляю текст |
| Термин или жаргон | паблишер, программатик | Добавляю основу в словарь |
| Сокращение как «конец предложения» | т.к., см., 500 тыс. рублей | Переписываю: так как, 500 000 рублей |
| Слово принято за имя | джуна требует Джуна | Переписываю формулировку |
Последние два случая словарём не лечатся: он гасит только орфографию, а это правила пунктуации и капитализации. Но это и к лучшему — переписать т.к. в так как или 500 тыс. в 500 000 полезно само по себе, текст от этого только чище. А джуна, которое LanguageTool принял за имя собственное (была такая известная Джуна), я поменял на джунов — и правило успокоилось, и заодно поправилось согласование в предложении.
Итог
Решение получилось скромным: LanguageTool, около двух сотен строк Go и текстовый словарь исключений. Но эффект ровно тот, который был нужен:
- ни один новый пост не уезжает в прод без проверки;
- старый контент чищу инкрементально, без марафонов;
- ложные срабатывания под контролем и не мешают работать.
Кстати, эта статья — первая, которую я писал, заранее зная, что её проверит мой же инструмент. Пару новых терминов пришлось тут же занести в словарь. Что ж, дашь себе инструмент — и он первым делом проверит тебя самого. А ту самую «месть для рекламы» я, конечно, давно исправил.
Теги: