Как я автоматизировал проверку правописания в блоге

Недавно я перечитывал свою старую статью про SSP и наткнулся на фразу: рекламный инвентарь — это «месть для рекламы». Разумеется, я имел в виду «место для рекламы». Опечатка пережила мою вычитку, спокойно проехала публикацию и месяцами жила в проде. Тогда я решил, что так больше нельзя: проверку правописания пора автоматизировать. В этой статье расскажу, как я встроил в свой Hugo-блог автоматическую проверку орфографии и пунктуации и как теперь постепенно вычищаю накопленные ошибки в старых материалах.

Почему вычитки глазами недостаточно

Своя опечатка — самая незаметная. Глаз привыкает, мозг достраивает знакомый текст, и «месть» читается как «место». Чем больше постов и чем чаще пишешь, тем выше шанс, что одна из них доедет до читателя.

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

Каким я хотел видеть решение

Прежде чем писать код, я сформулировал требования:

Движок: 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 и текстовый словарь исключений. Но эффект ровно тот, который был нужен:

Кстати, эта статья — первая, которую я писал, заранее зная, что её проверит мой же инструмент. Пару новых терминов пришлось тут же занести в словарь. Что ж, дашь себе инструмент — и он первым делом проверит тебя самого. А ту самую «месть для рекламы» я, конечно, давно исправил.


Теги: