AI-черновик тире, ёлочки, "важно отметить" lint_post.sh 11 проверок PCRE-паттерны exit 0 / exit 1 exit 0 git commit проходит exit 1 коммит заблокирован выводим строки правка + повтор
Барьер перед репозиторием. Статья проходит проверку при каждом git commit. При нарушениях выводятся конкретные строки с номерами.
11 категорий проверок в lint_post.sh
5 статей прошли полную очистку от нарушений
~200 строк Bash без внешних зависимостей кроме Perl
1 баг кириллица и PCRE \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/&nbsp;/ /g; s/&amp;/\&/g; s/&lt;/</g; s/&gt;/>/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: все проверки пройдены"]
            
Поток обработки. Perl-стриппинг превращает HTML в чистый текст, после чего PCRE-паттерны проверяют только прозу.

Кириллица против 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
            
Последовательность вызовов при git commit. Линтер запускается строго для изменённых файлов.

Результаты: пять статей через очистку

К моменту написания 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. Одно тире убрано.

Самая неожиданная находка при очистке: тире-разделитель оказывается диагностикой ритма фразы. Каждый раз, когда мы его убирали, предложение приходилось перестраивать. Результат оказывался точнее и короче.

После очистки: все пять статей проходят lint_post.sh без ошибок.

git pre-commit hook активен для всех новых коммитов в domains/blog.epich.ru/public/. Каждая новая статья автоматически проходит 11 проверок перед тем, как попасть в репозиторий.

Самый важный выигрыш от автоматизации здесь не в экономии времени, а в снятии нагрузки на память. До линтера качество статьи зависело от того, насколько хорошо автор помнил список запретов в конкретный день. После линтера правила закодированы в скрипте и проверяются при каждом коммите независимо от состояния автора.