Парсинг дат в JS — добавляем русский язык в библиотеку Chrono

Crono это парсер дат на естественном языке. Кроме формальных ISO 8601 или dd.MM.yyyy, распознает варианты а-ля «в среду утром‎», «с 10 до 11 вечера»,»2 часа 5 минут назад» и т.п. Поддерживает 8 языков, в том числе, теперь, и русский.

44d66f8cc481afbe66d91b7406d7b026.jpeg

Использую Chrono в своем проекте начиная с 2016 года. Русский язык библиотека не поддерживала, но удалось найти форк, который сносно парсил даты на русском. Прошло 6 лет, библиотека развивалась, в 2020 автор переписал её на TypeScript, переработав архитектуру, а поддержки русского языка в официальном репозитории так и не появилось. Решил это исправить. PR вмёрджили, можно и статью написать.

Добавление нового языка

Для добавления нового языка нужно добавить специфичные для него парсеры (parsers) и рефайнеры (refiners). Из них состоит пайплайн в Chrono.

Парсеры

Их задача извлечь из строки соответствующие форматы дат. Идеально один, или смежные форматы — один парсер. Для русского их сейчас 10. Например, RUCasualDateParser находит даты вида сегодня|вчера|завтра|послезавтра|позавчера.

Интерфейс парсера:

interface Parser {
    pattern: (context: ParsingContext) => RegExp для поиска дат,
    extract: (context: ParsingContext, match: RegExpMatchArray) =>
        Результат извлечения
}

Рефайнеры

Задача —фильтрация полученных результатов, их сортировка, объединение, обогащение дополнительной информацией. Например, строка «в пятницу в 19:00». Парсер дней недели извлёк «в пятницу», парсер времени «в 19:00», задача рефайнеров это объединить.

Основную работу делает набор общих для всех языков рефайнеров. Специфичных для языка обычно 2 или 3. Наследуются от стандартных классов, и выглядят, как правило, так:

export default class RUMergeDateRangeRefiner 
  extends AbstractMergeDateRangeRefiner {
    patternBetween(): RegExp {
        return /^\s*(и до|и по|до|по|-)\s*$/i;
    }
}

Интерфейс:

interface Refiner {
    refine: (context: ParsingContext, results: ParsingResult[]) 
					=> ParsingResult[]
}

Использование

По умолчанию используется английский, к русской локали обращаемся через chrono.ru.

import * as chrono from 'chrono-node';

chrono.ru.parseDate('Встреча 12 сентября');
// 2022-09-12T08:00:00.000Z
    
chrono.ru.parse('Встреча 12 сентября');
/* [{ 
    index: 18,
    text: '12 сентября',
    start: ...
}] */

parseDate вернёт вам первую встретившуюся дату как стандартный Date, parse вернёт все найденные даты как ParsedResult.

export interface ParsedResult {
		/**
     * Дата относительно которой производился поиск, подробнее ниже.
     */
    readonly refDate: Date;
		/**
     * Индекс найденного вхождения. 
     * Для строки 'Встреча 12 сентября' будет 8.
     */
    readonly index: number;
		/**
     * Текст найденной датой. Для строки 'Встреча 12 сентября'  
		 * будет '12 сентября'.
     */
    readonly text: string;
		/**
     * Либо найденную дату, если, например, на вход подать '12 сентября'. 
		 * Либо начало интервала, если строка была, 
     * например, '10 - 22 августа 2012'.
     */
    readonly start: ParsedComponents;
		/**
     * Конец интервала, если найден интервал, а не единичная дата.
     */
    readonly end?: ParsedComponents;
    /**
     * Создает экземпляр Date из result.start.
     */
    date(): Date;
}

Свойства start и end имеют тип ParsedComponents. Содержат не только дату, но и немного метаинформации. Например, метод isCertain.

chrono.ru.parse('утром')[0].start.isCertain('day');
//false — тут день предполагается
chrono.ru.parse('12 сентября утром')[0].start.isCertain('day');
//true — тут чётко понятно, какой день

Оба метода принимают дополнительные опции:

function parse(text: string, ref?: Date, option?: ParsingOption)
			: ParsedResult[] {
    ...
}
function parseDate(text: string, ref?: Date, option?: ParsingOption)
      : Date {
    ...
}

Параметр ref нужен для того, чтобы задать контекст. Если сказать «сегодня в 21:45» сейчас и через месяц, будут иметься ввиду разные моменты времени. По умолчанию new Date().

В ParsingOption есть 2 полезных свойства:

export interface ParsingOption {
    /**
     * Дает понять, что результат должен быть после reference date.
     */
    forwardDate?: boolean;

    /**
     * Можно переопределить смещение временных зон
     */
    timezones?: { [tzKeyword: string]: number };
}

Пример:

const referenceDate = new Date(2012, 7, 25);
// 25 августа 2012, суббота 

chrono.ru.parseDate('в пятницу', referenceDate);
// 24 августа 2012

chrono.ru.parseDate('в пятницу', referenceDate, { forwardDate: true });
// 31 августа 2012, пятница

Есть строгая версия парсера — chrono.ru.strict. Состоит из парсеров, понимающих только формальные паттерны.

chrono.ru.strict.parseDate('сегодня');
// null
chrono.ru.strict.parseDate('06.07.2020');
// 6 июля 2022

Примеры поддерживаемых форматов

  • прошлым вечером

  • 10 августа — 12 сентября 2013

  • 24 го октября, 9:00

  • в 12

  • полчаса назад

  • через месяц

  • в прошлый четверг

  • 06.09.2012

  • 10:00:00 — 21:45:01

и т.д.

PRs are welcome

За образец брал парсеры английского, поэтому какие-то специфичные для русского языка паттерны могли просто не прийти в голову. Если придут вам, будет супер, если создадите PR, или хотя бы ишью. Если ишью, можете отмечать меня.

GitHub
npm

© Habrahabr.ru