cronsense: как парсер расписаний научился понимать «полвторого» и RRULE после хабратестирования
Библиотека cronsense, переводящая фразы на естественном языке в cron-выражения, за несколько дней пережила семь обновлений после критики пользователей Хабра. Разработчик добавил поддержку разговорных форм времени, строгий режим для неоднозначных выражений и генерацию RRULE для календарных задач.
В начале октября 2026 года автор библиотеки cronsense, предназначенной для преобразования текстовых описаний расписаний в cron-выражения, столкнулся с волной критики на Хабре. Пользователи устроили так называемое хабратестирование, проверив парсер на живых примерах разговорной речи. Выяснилось, что инструмент не справляется с фразами вроде «полвторого», «без пяти шестнадцать» или «каждые полчаса». За несколько дней разработчик выпустил семь версий, существенно расширив возможности библиотеки и добавив поддержку формата RRULE из RFC 5545.
Первым делом пришлось научить парсер понимать числительные словами. Теперь «три», «двадцать пять» и даже «сорока» преобразуются в числа на этапе лексического анализа, а составные конструкции вроде «двадцать пять» корректно склеиваются. Это позволило обрабатывать фразы «каждые двадцать пять минут» и «в три утра» по тем же правилам, что и цифровые обозначения. Отдельная логика потребовалась для порядковых числительных в родительном падеже: выражение «пять минут седьмого» интерпретируется как 6:05, а «полвторого» — как 1:30. Правило простое: «N-го часа» означает, что идёт N-й час, то есть прошло N−1 полных часов. Из этого следует, что «первого» считается двенадцатым часом, поэтому «полпервого» — это 12:30, а «полпервого ночи» — 0:30. Также реализован отсчёт назад: «без четверти три» даёт 2:45, «без пяти шестнадцать» — 15:55. Обнаружилась забавная неоднозначность: во фразе «без двадцати один» лексер склеивает «двадцати» и «один» в 21, и для часа ничего не остаётся, хотя человек имеет в виду 12:40. Решение простое: если после составного числа нет часа, последнее слово считается часом. Для проверки всех этих правил был создан генератор тестов, который создаёт 1584 фразы, охватывающие все двенадцать часов и варианты «утра», «дня», «вечера», «ночи» и «пополудни». Ожидаемое время для каждой фразы вычисляется отдельной формулой, что позволяет автоматически проверять корректность парсера.
Второй важный вопрос поднял пользователь @zgwerby: как библиотека определяет, что «без шести семь» — это утро, а не вечер? Из одной фразы это действительно непонятно. По умолчанию часы читаются так, как сказаны: «без шести семь» — это 6:54, а «в 7» — 7:00. Слова «утра», «дня», «вечера», «ночи» и «пополудни» переключают половину суток, а числа от 13 до 23 однозначны сами по себе. Однако для ботов-напоминалок молчаливый выбор утра может быть опасен, поэтому появился строгий режим. При вызове toCron('без шести семь', { strictHours: true }) библиотека возвращает ошибку AMBIGUOUS, требуя добавить уточнение или использовать 24-часовой формат. Например, toCron('без шести семь вечера', { strictHours: true }) даст '54 18 * * *', а toCron('в 09:30', { strictHours: true }) — '30 9 * * *', где ведущий ноль означает 24-часовой формат. Также были вопросы про «каждое десятилетие» и «каждый век». Поскольку в стандартном cron пять полей и нет поля года, такие фразы теперь выдают понятную ошибку «интервалы длиннее года cron не выражает» вместо «незнакомое слово». А «каждые полчаса» просто превращаются в */30 * * * *.
Третьим направлением стала поддержка RRULE, на которую указал пользователь @Biga. Оказалось, что почти всё, от чего cronsense отказывался, в RRULE выражается штатно. Парсер для cron и для RRULE общий, разница лишь в ограничениях: для RRULE разрешены недели, «последний», «первый понедельник» и сочетание числа месяца с днём недели через логическое И, а не ИЛИ, как в cron. Например, toRRule('в последний день месяца в 18:00') вернёт FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=18;BYMINUTE=0. Выражение «в первый понедельник месяца в 9:30» преобразуется в FREQ=MONTHLY;BYDAY=1MO;BYHOUR=9;BYMINUTE=30. Для «каждые 2 недели по понедельникам в 10» получится FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=10;BYMINUTE=0, а «каждые 90 минут» — FREQ=MINUTELY;INTERVAL=90. Одно решение требует пояснения: в RRULE есть INTERVAL, и «каждые 2 часа» можно записать как FREQ=HOURLY;INTERVAL=2. Но такой интервал отсчитывается от DTSTART, и при старте в 9:00 запуски пойдут в 9, 11, 13, а не в 8, 10, 12. Поэтому шаги, которые делят час или сутки нацело, превращаются в явные списки BYMINUTE и BYHOUR. Они не зависят от DTSTART и совпадают с cron. INTERVAL остаётся только там, где без него не обойтись: 90 минут, 3 дня, 2 недели.
Отдельно @Biga пожаловался на странное поведение rrule.js: если поставить DTSTART на начало месяца, события вида «каждую пятницу» съезжают. Воспроизведение показало, что дело не в дне недели. Например, FREQ=WEEKLY;BYDAY=FR;BYHOUR=19 с DTSTART в четверг даёт правильные даты: 2, 9, 16, 23 октября. Но если DTSTART установлен на 00:00 МСК, то из-за того, что rrule.js считает в UTC, локальная полночь по Москве — это 21:00 UTC предыдущего дня, и пятничные события в местном времени уезжают на субботу. Отсюда возникла вторая просьба: считать даты без начальной даты вообще. Для этого появилась функция occurrences, которая принимает диапазон from и to, а также опциональный anchor для настоящих интервалов. Например, occurrences('по пятницам в 19:00', { from: new Date(2026, 9, 1), to: new Date(2026, 9, 31) }) вернёт все пятницы октября в 19:00 без DTSTART. Точка отсчёта нужна только для интервалов, которые нельзя выразить без неё.
Все новые правила тестируются через rrule.js как независимый оракул: берут правило, считают по нему даты с помощью rrule.js и сравнивают с датами, которые даёт cron для той же фразы. Эта сверка выявила два бага в самом парсере. «В полчаса еженедельно» теряло неделю, а «еженедельно и ежемесячно» давало бессмысленное расписание. Теперь последнее честно отклоняется как противоречие. Таким образом, за короткий срок cronsense превратился из простого конвертера в cron в полноценный инструмент для работы с расписаниями на естественном языке, поддерживающий как cron, так и RRULE, с гибкими настройками строгости и возможностью генерации дат без привязки к DTSTART. Это делает библиотеку пригодной для более широкого круга задач, включая календарное планирование и напоминания, где важна однозначность интерпретации времени.