\b несовместимы
Сбор запретов
Отправная точка была практической. У нас накопился список стилистических нарушений, которые одинаково часто появлялись в черновиках с AI-помощью. Мы свели их в формальный список запретов в SKILL.md и добавили туда же рекомендации по тону для постов в стиле Habr. Список получился из двух частей: типографика и ИИ-клише.
Типографика дала четыре категории. Тире-разделитель в роли паузы между частями фразы рождает канцелярский ритм и мешает читать вслух. Двойной дефис в той же роли приходит из английской традиции и в русском тексте выглядит как опечатка. Кавычки-ёлочки хорошо смотрятся на бумаге, но плохо читаются в HTML рядом с кодом, поэтому мы перешли на прямые кавычки. Сокращения по списку: т.к., т.е., и др., т.п. превращают абзац в конспект, а не в статью.
ИИ-клише дали ещё семь позиций. Слово просто появляется в текстах LLM каждый раз, когда модель хочет замаскировать отсутствие объяснения. Конструкция это не X, это Y читается как инструкция, за которой живого объяснения нет. Список клише фиксированный: важно отметить, в заключение, в современном мире, таким образом, следует отметить, на сегодняшний день, давайте рассмотрим. Плюс плотность частицы не выше 3% коррелирует с лекторским тоном, а короткие фрагменты из одного-трёх слов бьют по ритму. Финальная пара проверок касается технических требований HTML: наличие скрипта nav.js, highlight.js и вызова hljs.highlightAll().
- §1. Тире-разделитель
"—"в абзацах длиной от 40 символов - §1b. Двойной дефис
" -- "с пробелами вокруг - §2. Кавычки-ёлочки (символы U+00AB и U+00BB)
- §3. Сокращения по словарю:
т.к.,т.е.,и др.,т.п.,т.д.,напр.,см. - §4. Слово
просто - §5. ИИ-клише по списку из 12 фраз
- §5b. Конструкция "это не X, это Y"
- §6. Предложения из 1-3 слов в тексте параграфов (порог больше пяти)
- §7. Плотность "не" выше 3% от всех слов текста
- §8a. Наличие тега скрипта nav.js в файле
- §8b. Наличие highlight.js и вызова hljs.highlightAll()
Архитектура: почему нельзя грепать по необработанному HTML
Первое желание при написании линтера для HTML-статьи: пройтись grep-ом по тексту файла и считать совпадения. Проблема в том, что HTML-файл содержит несколько слоёв, которые дают ложные срабатывания. Блоки кода в теге <pre><code> часто содержат строки вроде // важно проверить или аргументы командной строки с двойным дефисом. Стили в теге <style> используют CSS-переменные вида --bg и --card. Скрипты в теге <script> содержат строки, вырванные из контекста русского текста.
Решение: перед проверками вытащить только текст прозы, убрав все блоки разметки, кода и навигации. Мы используем Perl для этого этапа, потому что его PCRE поддерживает многострочный режим и позволяет элегантно убрать вложенные теги одним регулярным выражением.
if command -v perl &>/dev/null; then
RAW_TEXT="$(perl -0777 -pe '
s/<script[^>]*>.*?<\/script>//gis;
s/<style[^>]*>.*?<\/style>//gis;
s/<svg[^>]*>.*?<\/svg>//gis;
s/<pre[^>]*>.*?<\/pre>//gis;
s/<code[^>]*>.*?<\/code>//gis;
s/<span[^>]*class="[^"]*(?:inline-code|code)[^"]*"[^>]*>.*?<\/span>//gis;
s/<nav[^>]*>.*?<\/nav>//gis;
s/<header[^>]*>.*?<\/header>//gis;
s/<footer[^>]*>.*?<\/footer>//gis;
s/<[^>]+>//g;
s/ / /g; s/&/\&/g; s/</</g; s/>/>/g;
' "$FILE")"
fi
Флаг -0777 заставляет Perl читать файл целиком как одну строку, что необходимо для мультистрочных замен. Модификатор s в регулярных выражениях позволяет точке совпадать с символом перевода строки. Флаг i делает поиск тегов нечувствительным к регистру.
Остаётся запасной вариант через sed для систем без Perl: простое удаление всех тегов вида s/<[^>]*>//g. Он менее точен при мультистрочных блоках кода, но не приводит к сбою на любой Unix-системе.
flowchart TD
A["HTML-файл статьи"] --> B["perl -0777 многострочный режим"]
B --> B1["убрать script, style, svg блоки"]
B1 --> B2["убрать pre, code, inline-code блоки"]
B2 --> B3["убрать nav, header, footer"]
B3 --> B4["убрать все теги: s/<[^>]+>//g"]
B4 --> C["RAW_TEXT — только проза"]
C --> D1["§1 тире grep -cP '—'"]
C --> D2["§2 ёлочки grep -cP '[«»]'"]
C --> D3["§3 сокращения словарь"]
C --> D4["§4 просто lookaround PCRE"]
C --> D5["§5 ИИ-клише по списку"]
C --> D6["§6 короткие предложения"]
C --> D7["§7 плотность «не»"]
D1 --> E{"есть нарушения?"}
D2 --> E
D3 --> E
D4 --> E
D5 --> E
D6 --> E
D7 --> E
E -->|да| F["exit 1: показать строки"]
E -->|нет| G["exit 0: все проверки пройдены"]
Кириллица против PCRE \b: почему граница слова нас подвела
Здесь находилась самая неожиданная техническая ловушка. Когда мы написали проверку для слова просто, первая версия выглядела разумно:
grep -ciP '\bпросто\b'
Паттерн \b в PCRE означает границу слова: переход между символом класса \w и символом класса \W. Класс \w по умолчанию в PCRE совпадает только с ASCII-символами: [a-zA-Z0-9_]. Кириллические буквы попадают в класс \W.
Из этого следует неочевидное последствие. В тексте непросто решить буква "е" перед просто и буква "т" после "о" оба принадлежат классу \W. Граница \b требует переход \w→\W или \W→\w. Между двумя символами \W никакой границы нет. Паттерн \bпросто\b не сработает вообще в чисто русском тексте и никогда не поймает нарушение.
Проверка была написана, выглядела корректно, но фактически ничего не делала. Обнаружили это только при ручном тесте с очевидным нарушением: статья с десятком вхождений слова просто проходила чистой.
Функциональный grep без вывода ошибок и с некорректным паттерном ведёт себя идентично рабочему grep с нулевым числом совпадений. Разница видна только при наличии тестовых данных с известным результатом.
Фикс: заменить \b на lookaround по явному алфавиту:
# Было (не работало для кириллицы):
grep -ciP '\bпросто\b'
# Стало:
grep -ciP '(?<![а-яёА-ЯЁa-zA-Z])просто(?![а-яёА-ЯЁa-zA-Z])'
Lookahead (?![а-яёА-ЯЁa-zA-Z]) проверяет, что после просто не следует ни одна кириллическая или латинская буква. Lookbehind (?<![а-яёА-ЯЁa-zA-Z]) проверяет то же самое с другой стороны. Это корректно отсекает непросто и упростить, при этом поймает просто сделать или это просто.
Примечательно, что тот же паттерн нужен для любой проверки с границами слов в русском тексте через PCRE без флага Unicode. Это правило стоит запомнить: для кириллицы вместо \b всегда нужны lookaround-ы с явным алфавитом.
show_lines(): реальные номера строк при диагностике
Первая версия вывода нарушений использовала данные из RAW_TEXT. Проблема: RAW_TEXT получается после Perl-стриппинга, его строки не соответствуют строкам оригинального файла. Автор видит нарушение без контекста и без координат в файле.
Решение: для вывода диагностики grep-ить напрямую оригинальный файл $FILE с флагом -n. Строки из блоков кода тоже попадут в вывод, но мы их фильтруем простым grep-ом по типичным открывающим тегам.
show_lines() {
local pat="$1" max="${2:-5}" gflag="${3:--P}"
grep -n $gflag "$pat" "$FILE" 2>/dev/null \
| grep -vP '^\d+:\s*<(script|style|pre|svg)\b' \
| grep -vP '^\d+:\s*(//|<!--|/\*)' \
| head -"$max" \
| while IFS= read -r line; do
printf " \033[0;33m%s\033[0m\n" "$line" >&2
done
}
Теперь вместо бесконтекстного фрагмента текста автор видит:
✗ ОШИБКА: Тире '—': 3 вхождения в абзацах. Заменить на запятую, двоеточие или перестроить фразу.
42: Perl читает файл целиком — флаг 0777 включает этот режим.
67: Стриппинг нужен — иначе код попадёт в текстовую проверку.
89: Результат прост — граница слова в кириллице не работает.
Три числа слева дают точку входа в файл. Автор открывает нужную строку и сразу видит контекст. Без номеров строк нарушение в 200-строчном файле приходилось искать вручную.
Интеграция в precommit-check.sh
Сам lint_post.sh при запуске в терминале уже удобен. Но настоящая ценность приходит при подключении к git pre-commit хуку, потому что тогда проверка происходит автоматически, без дополнительного шага от разработчика.
В репозитории уже существует scripts/precommit-check.sh с набором check-функций для разных аспектов качества кода. Мы добавили туда функцию check_blog_lint_post, которая вызывается в цикле по изменённым HTML-файлам раздела безопасности:
check_blog_lint_post() {
local f="$1"
[[ -f "$f" ]] || return
[[ "$f" =~ domains/blog\.epich\.ru/public/ ]] || return
[[ "$(basename "$f")" == "index.html" ]] && return
local lint_script="$REPO_ROOT/.github/skills/blog-post/lint_post.sh"
[[ -f "$lint_script" ]] || return
local out exit_code
out=$(bash "$lint_script" "$f" 2>&1)
exit_code=$?
if [[ "$exit_code" -eq 0 ]]; then
ok "lint_post: $(basename "$f")"
else
printf '%s\n' "$out" | grep -E '^✗|^ [0-9]' >&2
err "lint_post: нарушения стиля в $f"
fail=$((fail + 1))
fi
}
Несколько деталей достойны объяснения. Проверка basename "$f" == "index.html" исключает листинговую страницу блога: там есть фрагменты разметки, которые технически нарушают правила стиля, но находятся вне зоны ответственности линтера. Функция вызывается для файлов из переменной changed_html_files, то есть только для файлов, изменённых в текущем коммите. Это гарантирует, что старые непроверенные статьи не будут блокировать каждый коммит.
sequenceDiagram
participant D as Разработчик
participant G as git commit
participant P as precommit-check.sh
participant L as lint_post.sh
D->>G: git commit -m "feat(blog): новая статья"
G->>P: pre-commit hook
P->>P: changed_html_files = изменённые *.html
loop для каждого изменённого HTML
P->>L: check_blog_lint_post(file)
L->>L: perl стриппинг HTML
L->>L: PCRE проверки тире, ёлочек, клише
alt нарушений нет
L-->>P: exit 0
P-->>P: ok "lint_post: имя файла"
else есть нарушения
L-->>P: exit 1 + вывод строк
P-->>P: err + fail++
end
end
alt fail == 0
P-->>G: exit 0
G-->>D: коммит создан
else fail > 0
P-->>G: exit 1
G-->>D: коммит заблокирован
end
Результаты: пять статей через очистку
К моменту написания lint_post.sh в блоге было пять статей. Мы прогнали каждую через линтер и устранили все нарушения.
| Статья | Нарушений до | Основные правки |
|---|---|---|
| watch-two-bugs.html | 14 | Тире в паузах заменены на запятые и двоеточия. Убраны т.к., т.е.. Убраны три телеграфных предложения из одного слова. |
| watch-together-fixes.html | 11 | Кавычки-ёлочки в прямых цитатах заменены на прямые. Убраны два важно отметить. Тире снова заменены на запятые. |
| bomj-game-engine.html | 8 | Конструкция это не A, это B переписана как развёрнутое объяснение. Убраны сокращения. |
| docs-audit.html | 5 | Тире и одно таким образом. Статья уже была чище остальных. |
| blog-post-skill.html | 3 | Ёлочки в примерах кода переместились в теги code. Одно тире убрано. |
Самая неожиданная находка при очистке: тире-разделитель оказывается диагностикой ритма фразы. Каждый раз, когда мы его убирали, предложение приходилось перестраивать. Результат оказывался точнее и короче.
git pre-commit hook активен для всех новых коммитов в domains/blog.epich.ru/public/. Каждая новая статья автоматически проходит 11 проверок перед тем, как попасть в репозиторий.
Самый важный выигрыш от автоматизации здесь не в экономии времени, а в снятии нагрузки на память. До линтера качество статьи зависело от того, насколько хорошо автор помнил список запретов в конкретный день. После линтера правила закодированы в скрипте и проверяются при каждом коммите независимо от состояния автора.