«По будням в 9:30» → 30 9 * * 1–5: парсер расписаний на русском, который отказывается угадывать
- вторник, 29 сентября 2026 г. в 00:00:07
Выражение */15 9-17 * * 1-5 компьютер понимает однозначно, а человек читает с трудом. Особенно это мешает, когда расписание задаёт пользователь: в боте‑напоминалке, в админке, в настройках отчётов. Человек хочет написать «каждые 15 минут с 9 до 18 по будням» и увидеть, что его поняли правильно. Писать для этого форму с пятью выпадающими списками не хочется никому.
Готовых решений для русского языка я не нашёл, поэтому написал своё.
Библиотека называется cronsense. Она переводит расписание, записанное обычными словами на русском или английском, в cron‑выражение и обратно:
по будням в 9:30 → 30 9 * * 1-5 каждые 15 минут с 9 до 18 по будням → */15 9-17 * * 1-5 1 и 15 числа в полдень → 0 12 1,15 * * с ноября по февраль в 7 утра → 0 7 * 1,2,11,12 *
Попробовать можно в браузере: https://mrprolopstar.github.io/cronsense/
Ниже про то, как это устроено внутри и какие грабли встретились по дороге.
Для направления «cron → текст» давно есть cRonstrue, и у него есть русская локаль. В обратную сторону, из свободного текста в cron, я нашёл в основном билдеры вида schedule.onDays(...).at('09:00'). Это удобный API, но пользователь бота не пишет код, он пишет «напоминай по будням в девять».
Можно отдать фразу LLM. Для прототипа это работает, но модель может ошибиться в cron уверенно и молча, например перепутать день недели с числом месяца или не учесть, что 9-18 в поле часов включает 18:00. Мне нужен был результат, который одинаков при каждом запуске и который можно покрыть тестами.
Отсюда и главный принцип библиотеки: если cron не может выразить фразу точно, она возвращает ошибку с объяснением и не подставляет что‑то похожее.
Конвейер обычный: лексер, словарь, парсер, модель полей cron.
Лексер режет строку на числа, время (9:30, 9.30), слова и разделители. Он помнит позицию каждого токена в исходной строке. Это пригодилось для ошибок: библиотека показывает, какое именно слово ей не понравилось.
по будням кроме пятницы ^^^^^ UNSUPPORTED: Not expressible in cron: exclusions
Словарь сопоставляет слова с регулярками по основам, словоформы в нём не перечислены. Для дней недели это выглядит так:
[/^(?:понедельник(?:а|ам|и|ов|у)?|пн|mondays?|mon)$/, dow(1)], [/^(?:сред(?:а|у|ам|ы|е)|ср|wednesdays?|wed|weds)$/, dow(3)], [/^(?:будн[а-я]*|рабоч[а-я]*|weekdays?|workdays?)$/, { t: 'dow', days: WEEKDAYS }],
Словарь морфологии здесь избыточен: предметная область маленькая, слов около сотни. Регулярки по основам покрывают «понедельник», «понедельникам», «с понедельника» и английские формы одной строкой.
Интереснее с неоднозначными словами. «Дня» бывает единицей измерения («каждые 3 дня») и указанием времени суток («в 3 часа дня»). В словаре это один токен с двумя смыслами, а выбирает контекст: после «каждые N» это интервал, после часа это «после полудня».
Парсер написан рекурсивным спуском. Самое неприятное место в нём: голые числа. «В 15» означает время, «15 числа» означает день месяца, а просто «15» непонятно что. На последний случай библиотека отвечает ошибкой AMBIGUOUS и подсказывает дописать «в» или «числа».
Сложнее всего оказались как раз такие стыки между правилами. Чтобы новое правило не сломало старую фразу незаметно, на русские и английские формулировки сразу завёл табличные тесты: фраза и ожидаемый cron. Сейчас в проекте 233 теста.
Выражение 0 0 1 * 1 многие читают как «первое число, если это понедельник». На деле cron запустит задачу каждый понедельник и каждое первое число. Поэтому фразу «по понедельникам 1 числа» cronsense не превращает в cron, а возвращает ошибку CONFLICT. Явный отказ здесь полезнее молча выданного другого расписания.
«Каждые 15 минут с 9 до 18» люди понимают так: последний запуск в 17:45. Значит, в поле часов должно стоять 9-17, а не 9-18. А вот «каждый час с 9 до 18» обычно включает 18:00, и там 9-18. Эти два правила пришлось закодировать явно.
«Каждые 2 недели», «в последний день месяца», «каждые 90 минут» стандартный cron точно не выражает. Всё это даёт ошибку UNSUPPORTED или OUT_OF_RANGE с объяснением. Если число минут кратно 60, например 120, библиотека подсказывает записать интервал в часах.
«В 9:00 и 18:30» одной cron‑строкой не записать: поле минут общее для всех часов, и 0,30 9,18 включит лишние 9:30 и 18:00. Сетку «9:00, 9:30, 18:00, 18:30» записать можно, её парсер принимает.
Функция describe делает из cron текст:
describe('0 23 * * 0,1,5,6', { locale: 'ru' }) // 'с пятницы по понедельник в 23:00' describe('0 9 * * 1-3,5', { locale: 'ru' }) // 'с понедельника по среду и по пятницам в 9:00' describe('15 * * * *', { locale: 'ru' }) // 'каждый час в 15 минут'
Главное требование к ней: всё, что она пишет, должно разбираться обратно в то же самое расписание. Проверяет это property‑тест. Он генерирует 3000 случайных cron‑выражений, описывает каждое на русском и на английском, разбирает описание и сравнивает результат с исходным расписанием. Сравнивает не строки, а множества минут, часов и месяцев, плюс день месяца и день недели с учётом правила ИЛИ. Строки могут отличаться: 5-59/10 и 5,15,25,35,45,55 означают одно и то же.
Ради этого требования пришлось расширить грамматику парсера. Например, без фразы «каждый час в 15 минут» было нечем описать cron вида 15 * * * *, и её добавили в обе стороны.
Исключение одно: если в cron ограничены и день месяца, и день недели, описание получается «1 числа или по понедельникам». Такую фразу парсер намеренно не принимает, по той же причине с ИЛИ.
Для такого кода property‑тест полезнее десятков ручных примеров. Ручные примеры проверяют то, о чём я подумал, а случайные выражения вроде 5-59/10 22-23,0-5 /2 1,7 проверяют то, о чём я не подумал.
Раз LLM путаются в cron, логично дать им инструмент. В пакете есть cronsense-mcp: MCP‑сервер с тремя инструментами (to_cron, describe_cron, next_runs). Он без зависимостей: протокол MCP поверх stdio сводится к построчному JSON‑RPC, и отдельный SDK для трёх инструментов не нужен.
claude mcp add cronsense -- npx -y -p github:MrProLopstar/cronsense cronsense-mcp
Пакет опубликован в JSR, работает в Node, Deno, Bun и в браузере:
npx jsr add @mrprolopstar/cronsense
import cron from 'node-cron'; import { toCron } from '@mrprolopstar/cronsense'; cron.schedule(toCron('по будням в 9:30'), sendDailyReport);
Для ботов удобен такой приём: разобрать то, что написал пользователь, и ответить каноническим описанием, чтобы он увидел, как его поняли.
const result = safeParse(message.text); reply(result.ok ? `Буду напоминать ${describe(result.schedule, { locale: 'ru' })}` : result.error.excerpt);
Код открыт под MIT: https://github.com/MrProLopstar/cronsense. Если какая‑то фраза не разобралась или разобралась неправильно, откройте issue с этой фразой. Такие примеры сразу становятся тестами.