Выражение */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 теста.
Грабли cron, о которые легко споткнуться
День месяца и день недели объединяются через ИЛИ
Выражение 0 0 1 * 1 многие читают как «первое число, если это понедельник». На деле cron запустит задачу каждый понедельник и каждое первое число. Поэтому фразу «по понедельникам 1 числа» cronsense не превращает в cron, а возвращает ошибку CONFLICT. Явный отказ здесь полезнее молча выданного другого расписания.
Концы диапазонов
«Каждые 15 минут с 9 до 18» люди понимают так: последний запуск в 17:45. Значит, в поле часов должно стоять 9-17, а не 9-18. А вот «каждый час с 9 до 18» обычно включает 18:00, и там 9-18. Эти два правила пришлось закодировать явно.
Интервалы, которых в cron нет
«Каждые 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» записать можно, её парсер принимает.
Обратное направление и гарантия round‑trip
Функция 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 проверяют то, о чём я не подумал.
MCP‑сервер для ИИ‑агентов
Раз 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 с этой фразой. Такие примеры сразу становятся тестами.
Комментарии (15)

Rsa97
28.09.2026 14:02Слабовато пока что. Не понимает:
в полвторого ежедневно
в четверть третьего
в час дня
в пять минут седьмого
без пяти шестнадцать
zgwerby
28.09.2026 14:02В половину третьего// и вообще, любого часаБез шести семь// кстати, как он будет выбирать формат 12-24 и угадывать, какого семь?
Да, и общеупотребительные слова-маркеры часов русского языка он не парсит:Без четверти пополудни// точное указание на 12:00каждое десятилетие / каждый век// ЕМНИП, то крон умеет в годахв час ночи / в три утра / в три часа утра// Эээ? Не может распознать числительное "три"?Хабратестирование.

mrprolopstar Автор
28.09.2026 14:02Спасибо за «хабратестирование», всё из списка уже в релизе 1.2.0:
в половину третьего → 30 2 * * * (любого часа, с первого по двенадцатый) без шести семь → 54 6 * * * без четверти пополудни → 45 11 * * * в час ночи → 0 1 * * * в три утра → 0 3 * * * в три часа утра → 0 3 * * *Про 12/24 хороший вопрос. По умолчанию часы читаются как сказаны: «без шести семь» это 6:54, так же как «в 7» цифрами. «Утра», «дня», «вечера», «ночи» и «пополудни» переключают половину суток, а числа от 13 до 23 однозначны сами по себе. Для случаев, где ошибка дорого стоит, например в ботах-напоминалках, в 1.2.0 появился строгий режим
strictHours. В нём «без шести семь» без уточнения даёт ошибку с просьбой указать утро или вечер, а угадывания нет.Про годы: в стандартном cron пять полей, поля года там нет (оно есть в Quartz). Поэтому «каждое десятилетие» и «каждый век» теперь дают понятную ошибку «интервалы длиннее года cron не выражает».
Песочница: https://mrprolopstar.github.io/cronsense/

mrprolopstar Автор
28.09.2026 14:02Добрый день! Спасибо, учёл, проработал, выпустил патч 1.1.0, теперь он должен был закрыть такие косяки.

Rsa97
28.09.2026 14:02Ок, продолжаем тест :)
раз в полгода
ежеквартально
каждый чётный час
каждый третий час начиная с часа ночи
Ну и, возможно, стоит добавить формат таймера systemd.
mrprolopstar Автор
28.09.2026 14:02Спасибо за продолжение теста! Всё из списка сделал в 1.7.0:
раз в полгода → 0 0 1 */6 * ежеквартально → 0 0 1 */3 * каждый чётный час → 0 */2 * * * каждый третий час начиная с часа ночи → 0 1-23/3 * * *«Чётный» учитывается и внутри окна: «каждый чётный час с 9 до 18» начинается с 10.
Формат systemd тоже добавил,
toSystemdи флаг--systemdв CLI. Он умеет больше cron: последний день месяца (*-*~01), первый понедельник (Mon--01..07), число И день недели. Каждое выражение в тестах сверяется сsystemd-analyze calendar. Настоящие интервалы вроде «каждые 90 минут» OnCalendar не выражает, там библиотека подскажетOnUnitActiveSec=90min.

Biga
28.09.2026 14:02Очень надо такое же, только для rrule. Или даже не rrule а самодельная альтернатива даже лучше будет, потому что rrule страдает странными ограничениями.

mrprolopstar Автор
28.09.2026 14:02Спасибо, мысль хорошая. Многое из того, от чего cronsense сейчас отказывается, потому что cron это не выражает («каждые 2 недели», «в последний день месяца», «в первый понедельник месяца», «10 раз»), в RRULE как раз есть. Думаю добавить второй выход
toRRuleна том же парсере, со всеми русскими формами. А какие ограничения rrule мешают вам? Если это про конкретную библиотеку, а не про стандарт, то лучше учесть это сразу.
Biga
28.09.2026 14:02Вот с чем я столкнулся в rrule.js. Стандарт rrule якобы требует, чтобы была установлена начальная дата (dtstart). В библиотеке rrule.js, если не указать начальную дату, то по умолчанию будет поставлено "сегодня". Но ставит как-то ппц хитро, потому что события типа "каждую пятницу" при этом работают, даже если сегодня не пятница.
Почему я говорю, что это странно, потому что если установить дату dtstart вручную, скажем, на начало месяца, то это просто не будет работать, если первое число месяца внезапно не пятница. Библиотеку перекашивает и все события плывут. (Как бы логично, если для события "каждую пятницу" начальная дата внезапно понедельник. Тут любой растеряется.)
В итоге проблема не имеет решения. Если я устанавливаю начальную дату на начало месяца, то все события с днями недели ломаются. Если не устанавливаю, то события появляются только в конце календаря ("с сегодняшнего дня и далее"). Выставлять каждому событию начальную дату руками я не хочу, потому что это много бессмысленной работы. Логичнее было бы просто задать "каждый понедельник в 19:00" и мотать календарь назад и вперёд сколько хочу. Без начальной даты.
Вторая хотелка - это пасхальные даты. В rrule.js есть не совсем стандартный параметр byeaster (сделан по аналогии с python-dateutil). Около половины основных христианских праздников привязаны к Пасхе, так что с byeaster можно было бы внести эти праздники в календарь один раз, а не делать это каждый год вручную. Это то, чего мне не хватает, например, в google calendar.
mrprolopstar Автор
28.09.2026 14:02Спасибо, оба пункта сделал в версии 1.4.0.
Про DTSTART. Воспроизвёл вашу ситуацию. Дело не в дне недели начальной даты: «каждая пятница» от четверга rrule.js считает правильно. Проблема в том, что rrule.js работает в UTC, и когда в правиле нет
BYHOUR, время берётся из DTSTART. Локальная полночь по Москве - это 21:00 UTC предыдущего дня, поэтому пятничные события уезжают на субботу.В cronsense это решено двумя способами.
toRRuleвсегда пишет явныеBYHOUR/BYMINUTE, поэтому такого сдвига нет. А главное, появился свой вычислитель без начальной даты, как вы и хотели: задаёте правило один раз и «мотаете» календарь куда угодно:occurrences('по понедельникам в 19:00', { from, to })Точка отсчёта нужна только для «каждые 2 недели» и подобных: без неё в принципе неизвестно, какие недели считать.
Про Пасху. Есть нюанс:
BYEASTERв rrule.js и dateutil считает только западную Пасху, а православная часто с ней не совпадает (в 2026 году 12 апреля против 5-го). Поэтому в cronsense оба календаря:occurrences('через 49 дней после Пасхи', { from, to }) // Троица: 31 мая 2026 occurrences('за 46 дней до католической Пасхи', { from, to }) // Пепельная среда toRRule('49 days after easter') // FREQ=YEARLY;...;BYEASTER=49«Пасха» по умолчанию православная, «Easter» западная, можно уточнить словами или опцией. Для православной
toRRuleчестно отказывает и предлагаетoccurrences, потому что в RRULE её не выразить.Песочница: https://mrprolopstar.github.io/cronsense/. Если какая-то фраза не разберётся, пришлите её, добавлю в тесты.
danilovmy
Идея классная, сам что то подобное пробовал создать. Но в итоге стало понятно что, если я пишу с обикшами, то у меня нет шансов угадать, что закодировано в парсере. В итоге текст все равно идёт в ллм.
Кстати, с ноября 2025го модель (gpt > 4.xx) нормально так угадывает даты или диапазон. @mrprolopstar , а можно узнать на какие модели есть нарекания?
mrprolopstar Автор
Спасибо! С опечатками вы правы: парсер строгий, и на «по будянм» он честно скажет, что слово незнакомо, а не догадается. Для свободного текста LLM удобнее, спорить не буду.
Бенчмарка по конкретным моделям у меня нет, поэтому называть модели не стану. Речь не о том, что модели плохо понимают даты, а о нескольких местах, где ошибка молчаливая и правдоподобная:
-
0 0 1 * 1в cron означает «каждый понедельник или 1 число», а не «1 число, если это понедельник»; модели нередко генерируют такое для второго смысла;- концы диапазонов: «каждые 15 минут с 9 до 18» это
9-17в поле часов, а не9-18;- 12/24: «в семь» без уточнения.
Ответ модели при этом выглядит корректным cron, и глазами ошибку не видно.
Поэтому я бы не противопоставлял, а комбинировал: LLM переводит свободный текст в cron, а
describe()показывает пользователю, как это расписание будет понято («по будням в 9:30»). Если модель ошиблась, человек увидит это сразу. Для агентов есть MCP-сервер ровно для этого.