Парсер расписаний после хабратестирования: полвторого, RRULE, systemd и производственный календарь

—

от автора

В прошлой статье я показал cronsense, библиотеку, которая переводит расписание на русском или английском в cron: «по будням в 9:30» → 30 9 * * 1-5. Комментаторы устроили ей проверку, которую один из них метко назвал хабратестированием. Выяснилось, что библиотека не понимает «полвторого», «без пяти шестнадцать», «в три утра» и «каждые полчаса», а cron для части задач вообще не подходит.

За несколько дней вышло семь версий. Ниже разбор того, что изменилось, как это устроено и какие ошибки нашлись по дороге. Попробовать всё можно в песочнице: https://mrprolopstar.github.io/cronsense/

Как говорят о времени на самом деле

Первым пришёл комментарий @Rsa97 со списком фраз, которые парсер не понимал:

в полвторого ежедневно   → 30 1 * * *в четверть третьего      → 15 2 * * *в час дня                → 0 13 * * *в пять минут седьмого    → 5 6 * * *без пяти шестнадцать     → 55 15 * * *

в четверть третьего → 15 2 * * *

в час дня → 0 13 * * *

в пять минут седьмого → 5 6 * * *

без пяти шестнадцать → 55 15 * * *

Справа то, что библиотека выдаёт теперь. Для этого пришлось научить её трём вещам.

Числительные словами. «Три», «двадцать пять», «сорока» и прочие формы от одного до пятидесяти девяти превращаются в числа ещё в лексере, а составные вроде «двадцать пять» склеиваются. После этого «каждые двадцать пять минут» и «в три утра» идут по тем же правилам, что и цифры.

Порядковые числительные в родительном падеже. «Пять минут седьмого» означает 6:05, а «полвторого» означает 1:30. Правило одно: «N-го часа» значит «идёт N-й час», то есть прошло N−1 полных часов. Отсюда же неочевидное следствие, которое я закрепил явно: «первого» считается двенадцатым часом, поэтому «полпервого» это 12:30, а «полпервого ночи» 0:30.

Отсчёт назад. «Без четверти три» это 2:45, «без пяти шестнадцать» 15:55. Тут нашлась забавная неоднозначность: во фразе «без двадцати один» лексер честно склеивает «двадцати» и «один» в 21, и для часа ничего не остаётся. Человек же имеет в виду 12:40. Решение простое: если после составного числа нет часа, последнее слово считается часом.

Чтобы не проверять это вручную, тест генерирует все формы для всех двенадцати часов и всех вариантов «утра / дня / вечера / ночи / пополудни»: «в пол…», «в половину…», «в четверть…», «без четверти…», «в N минут…», «без N…». Получается 1584 фразы, и ожидаемое время для каждой считается отдельной формулой.

«Какого семь?»

@zgwerby задал хороший вопрос: как библиотека понимает, что «без шести семь» это утро, а не вечер? Никак. Из одной фразы это не понять.

По умолчанию часы читаются так, как сказаны: «без шести семь» это 6:54, так же как «в 7» цифрами это 7:00. Слова «утра», «дня», «вечера», «ночи» и «пополудни» переключают половину суток, а числа от 13 до 23 однозначны сами по себе.

Но для бота-напоминалки молча выбранное утро опасно, поэтому появился строгий режим:

toCron('без шести семь', { strictHours: true });// AMBIGUOUS: "без шести семь" could be morning or evening; add «утра»/«вечера», am/pm, or use 24-hour timetoCron('без шести семь вечера', { strictHours: true }); // '54 18 * * *'toCron('в 09:30', { strictHours: true });                // '30 9 * * *', ведущий ноль означает 24-часовой формат

// AMBIGUOUS: "без шести семь" could be morning or evening; add «утра»/«вечера», am/pm, or use 24-hour time

toCron('без шести семь вечера', { strictHours: true }); // '54 18 * * *'

toCron('в 09:30', { strictHours: true }); // '30 9 * * *', ведущий ноль означает 24-часовой формат

Там же был вопрос про «каждое десятилетие» и «каждый век». В стандартном cron пять полей, и поля года среди них нет, оно есть только в расширениях вроде Quartz. Поэтому такие фразы теперь дают понятную ошибку «интервалы длиннее года cron не выражает» вместо «незнакомое слово». А «каждые полчаса», о которых написал @Deosis, просто превращаются в */30 * * * *.

RRULE для того, чего cron не умеет

@Biga написал, что ему нужно то же самое, но для RRULE (RFC 5545), на котором работают календари. Это оказалось естественным продолжением: почти всё, от чего cronsense отказывался, в RRULE выражается штатно.

toRRule('в последний день месяца в 18:00');       // FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=18;BYMINUTE=0toRRule('в первый понедельник месяца в 9:30');    // FREQ=MONTHLY;BYDAY=1MO;BYHOUR=9;BYMINUTE=30toRRule('каждые 2 недели по понедельникам в 10'); // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=10;BYMINUTE=0toRRule('каждые 90 минут');                       // FREQ=MINUTELY;INTERVAL=90

toRRule('в первый понедельник месяца в 9:30'); // FREQ=MONTHLY;BYDAY=1MO;BYHOUR=9;BYMINUTE=30

toRRule('каждые 2 недели по понедельникам в 10'); // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;BYHOUR=10;BYMINUTE=0

toRRule('каждые 90 минут'); // FREQ=MINUTELY;INTERVAL=90

Парсер для cron и для RRULE общий. Разница только в том, какие ограничения включены: для RRULE разрешены недели, «последний», «первый понедельник» и сочетание числа месяца с днём недели через И, а не через ИЛИ, как в cron.

Одно решение стоит пояснить. В RRULE есть INTERVAL, и «каждые 2 часа» можно записать как FREQ=HOURLY;INTERVAL=2. Но такой интервал отсчитывается от DTSTART, и при старте в 9:00 запуски пойдут в 9, 11, 13, а не в 8, 10, 12. Поэтому шаги, которые делят час или сутки нацело, превращаются в явные списки BYMINUTE и BYHOUR. Они не зависят от DTSTART и совпадают с cron. INTERVAL остаётся только там, где без него никак: 90 минут, 3 дня, 2 недели.

Каждое правило тесты проверяют через rrule.js как независимый оракул: берут правило, считают по нему даты библиотекой rrule.js и сравнивают с датами, которые даёт cron для той же фразы. Эта сверка нашла два бага в самом парсере. «В полчаса еженедельно» теряло неделю, а «еженедельно и ежемесячно» давало бессмысленное расписание. Теперь последнее честно отклоняется как противоречие.

Почему «плыли» события

@Biga жаловался, что rrule.js ведёт себя странно: если поставить DTSTART на начало месяца, события вида «каждую пятницу» съезжают. Я воспроизвёл это, и дело оказалось не в дне недели:

FREQ=WEEKLY;BYDAY=FR;BYHOUR=19, DTSTART = четверг    → 2, 9, 16, 23 октября, всё верноFREQ=WEEKLY;BYDAY=FR,           DTSTART = 00:00 МСК  → 2026-10-02T21:00Z

FREQ=WEEKLY;BYDAY=FR, DTSTART = 00:00 МСК → 2026-10-02T21:00Z

rrule.js считает в UTC. Если в правиле нет BYHOUR, время берётся из DTSTART. Локальная полночь по Москве это 21:00 UTC предыдущего дня, и пятничные события в местном времени уезжают на субботу.

Отсюда вторая просьба @Biga: считать даты без начальной даты вообще. Для этого появилась функция occurrences:

occurrences('по пятницам в 19:00', { from: new Date(2026, 9, 1), to: new Date(2026, 9, 31) });// все пятницы октября в 19:00, без DTSTARToccurrences('каждые 2 недели по понедельникам в 19:00', { from, to, anchor: new Date(2026, 0, 5) });

// все пятницы октября в 19:00, без DTSTART

occurrences('каждые 2 недели по понедельникам в 19:00', { from, to, anchor: new Date(2026, 0, 5) });

Точка отсчёта нужна только для настоящих интервалов: без неё в принципе неизвестно, какие именно недели имеются в виду. Остальное считается в любом окне, вперёд и назад. Результаты сверены с rrule.js на десятках тысяч случайных фраз.

Второй раунд: полгода, чётные часы и systemd

@Rsa97 вернулся со словами «Ок, продолжаем тест» и новым списком:

раз в полгода                          → 0 0 1 */6 *ежеквартально                          → 0 0 1 */3 *каждый чётный час                      → 0 */2 * * *каждый третий час начиная с часа ночи  → 0 1-23/3 * * *

ежеквартально → 0 0 1 /3

каждый чётный час → 0 */2 * * *

каждый третий час начиная с часа ночи → 0 1-23/3 * * *

«Полгода» и «квартал» раскрываются в шесть и три месяца ещё в лексере, как раньше «полчаса». С чётностью есть тонкость: «каждый чётный час с 9 до 18» должен начинаться с 10, а не с 9, поэтому окно выравнивается под чётность. «Начиная с» открывает окно до конца суток, так что «каждый третий час начиная с часа ночи» даёт 1, 4, 7 и так до 22.

Там же было предложение поддержать формат таймеров systemd. Это оказалось удачной идеей: календарные события systemd умеют больше cron. У них есть последний день месяца, дни недели через И с числом, диапазоны внутри даты. Поэтому toSystemd строится из того же плана, что и RRULE:

toSystemd('по будням в 9:30');                   // Mon..Fri *-*-* 09:30:00toSystemd('в последний день месяца в 18:00');    // *-*~01 18:00:00toSystemd('в первый понедельник месяца в 9:30'); // Mon *-*-01..07 09:30:00toSystemd('на 23 февраля и 8 марта');            // две строки: *-02-23 00:00:00 и *-03-08 00:00:00

toSystemd('в последний день месяца в 18:00'); // -~01 18:00:00

toSystemd('в первый понедельник месяца в 9:30'); // Mon --01..07 09:30:00

toSystemd('на 23 февраля и 8 марта'); // две строки: -02-23 00:00:00 и -03-08 00:00:00

Проверять такое на глаз бессмысленно, поэтому в тестах работает настоящий systemd-analyze calendar: локально через WSL, в CI на Ubuntu. Все выражения отправляются одним вызовом, и ближайшие срабатывания сравниваются с тем, что считает сама библиотека. Эта сверка сразу нашла баг: фраза «в последнюю пятницу 1 числа» давала правило, которое никогда не срабатывает, потому что последняя пятница месяца не бывает первым числом. Теперь это ошибка «противоречие».

Настоящие интервалы вроде «каждые 90 минут» календарные события systemd не выражают, поэтому библиотека отказывает и подсказывает монотонный таймер OnUnitActiveSec=90min.

Праздники по названию

Вторая просьба @Biga касалась праздников, которые каждый год выпадают на разные даты. Теперь их можно называть по имени:

toCron('в День Победы в 10 утра');                 // '0 10 9 5 *'toCron('в католическое Рождество');                // '0 0 25 12 *'occurrences('на Масленицу', { from, to });         // вся неделя, 16–22 февраля 2026occurrences('в Троицу', { from, to });             // 31 мая 2026toRRule('good friday');                            // FREQ=YEARLY;...;BYEASTER=-2

toCron('в католическое Рождество'); // '0 0 25 12 *'

occurrences('на Масленицу', { from, to }); // вся неделя, 16–22 февраля 2026

occurrences('в Троицу', { from, to }); // 31 мая 2026

toRRule('good friday'); // FREQ=YEARLY;...;BYEASTER=-2

В таблице 42 праздника: государственные праздники РФ, православные и западные. Подвижные отсчитываются от Пасхи, и тут есть нюанс. В rrule.js для этого есть нестандартный параметр BYEASTER, но он знает только западную Пасху, а православная часто с ней не совпадает. Поэтому cronsense считает оба календаря сам: русские названия по умолчанию православные, английские западные, а уточнить можно словом «католическое» или опцией. Для православных дат toRRule честно отказывает и предлагает occurrences.

Рабочие дни и вторая библиотека

Самая практичная просьба не звучала в комментариях прямо, но напрашивалась: «в последний рабочий день месяца». Отчёты, зарплата, рассылки. Будний день тут не поможет: 31 декабря 2026 года выпадает на четверг, но это выходной.

Готового офлайн-календаря для JavaScript я не нашёл. Есть JSON-файлы в разных репозиториях, обёртки над онлайн-API isdayoff.ru и библиотеки на Python, Rust и PHP. Поэтому появилась вторая библиотека, prodcalendar: производственный календарь РФ с 2013 по 2026 год, около 5 КБ данных, без зависимостей.

Данные берутся из двух независимых источников, isdayoff.ru и xmlcalendar.ru, и сверяются по каждому дню. За 14 лет они разошлись ровно в одном: 8 мая 2020 года. Там же обнаружилось самое интересное. В 2020 и 2021 годах оба источника помечают выходными дни, которые стали нерабочими по указам президента. Юридически это не выходные: зарплата за них сохраняется, а норма часов по производственному календарю не меняется. Поэтому для таких дней в prodcalendar есть отдельный тип:

dayKind('2020-04-15');   // 'nonworking', нерабочий день по указуisWorkday('2020-04-15'); // false: в этот день не работалиstats(2020);             // { workdays: 248, hours: 1979, ... }, официальная норма

isWorkday('2020-04-15'); // false: в этот день не работали

stats(2020); // { workdays: 248, hours: 1979, ... }, официальная норма

Годовые нормы при 40-, 36- и 24-часовой неделе совпадают со статистикой источника до десятой часа для всех лет, кроме 2020 и 2021. Там расхождение намеренное: источник вычел ковидные дни, а prodcalendar выдаёт официальные цифры.

Ещё одна находка. Для 2027 года, календарь на который ещё не утверждён, isdayoff.ru уже отдаёт данные, но это заглушка, в которой все дни, включая 1 января, рабочие. Кто берёт данные только оттуда, получит неверный календарь. prodcalendar принимает год, только если оба источника совпадают и 1 января в них праздник. Раз в неделю GitHub Actions проверяет источники. Когда правительство утверждает календарь на новый год и оба источника с ним совпадают, GitHub сам прогоняет тесты, поднимает версию и публикует её в npm, так что обновить календарь можно обычным npm update.

cronsense при этом не зависит от prodcalendar. Календарь передаётся снаружи, поэтому подойдёт и календарь другой страны:

import { isWorkday } from 'prodcalendar';occurrences('в последний рабочий день месяца в 18:00', { from, to, isWorkday }); // 30 декабря 2026, а не 31-еoccurrences('в первый рабочий день месяца', { from, to, isWorkday });           // 12 января 2026occurrences('по рабочим дням в 9:30', { from, to, isWorkday });                 // без праздников, с рабочими субботами

occurrences('в последний рабочий день месяца в 18:00', { from, to, isWorkday }); // 30 декабря 2026, а не 31-е

occurrences('в первый рабочий день месяца', { from, to, isWorkday }); // 12 января 2026

occurrences('по рабочим дням в 9:30', { from, to, isWorkday }); // без праздников, с рабочими субботами

«Будний день» и «рабочий день» теперь разные вещи. Первый всегда означает понедельник–пятницу и выражается даже в RRULE через BYSETPOS. Второй зависит от праздников, и cron с RRULE его честно не принимают.

Баги, о которых я не знал

Самое полезное в этой истории даже не новые возможности, а ошибки, которые нашлись в старом коде.

«23 февраля и 8 марта» во всех прежних версиях превращалось в 0 0 8,23 2,3 *. Выглядит правдоподобно, но cron перемножает списки, и расписание срабатывало ещё 8 февраля и 23 марта. Теперь «число + месяц» запоминается как точная дата. Если даты складываются в сетку, как «1 и 15 января», получается cron. Если нет, cron отказывает, а occurrences выдаёт ровно две даты.

«По будням и по выходным» давало * 0-6 вместо . Формально то же самое, но сравнение строк ломалось. Это нашёл fuzz-тест, который гоняет 20 000 случайных фраз из словаря и проверяет, что библиотека не падает, а любой её результат разбирается обратно в то же расписание.

BYDAY=MO,1FR, то есть «все понедельники и первая пятница», по RFC допустимо, но rrule.js возвращает для такого правила пустой список. Выдавать правило, которое самая популярная библиотека понимает неправильно, плохо, поэтому такая смесь теперь отклоняется с советом разбить её на два правила.

Про LLM и опечатки

@danilovmy справедливо заметил, что если писать с ошибками, у парсера нет шансов, и текст всё равно уходит в LLM. Это правда: «по будянм» парсер не поймёт, а языковая модель поймёт. При этом противопоставлять их я не вижу смысла. Удобная схема такая: модель переводит свободный текст в расписание, а describe показывает человеку, как это расписание будет понято. Если модель ошиблась, человек увидит «буду напоминать по понедельникам или 1 числа» и заметит подвох сразу. Для агентов в пакете есть MCP-сервер с инструментами to_cron, to_rrule, to_systemd, describe_cron и next_runs.

Цифры и что дальше

В cronsense сейчас 598 тестов. Среди них 1584 сгенерированные разговорные фразы, fuzz на 20 000 фраз с проверкой round-trip, сверка тысяч правил RRULE с rrule.js и сотен выражений systemd с systemd-analyze. В prodcalendar 52 теста, и нормы всех лет сверены с источником.

В планах подсказки при опечатках («возможно, вы имели в виду „будням“?»), часовые пояса IANA вместо только местного времени и UTC. Оба пакета теперь есть в npm: npm install cronsense и npm install prodcalendar.

Ссылки:

Спасибо всем, кто тестировал библиотеку в комментариях к первой статье. Если какая-то фраза не разбирается или разбирается неправильно, присылайте её: такие примеры сразу становятся тестами.

ссылка на оригинал статьи https://habr.com/ru/articles/1089372/