@whiteslove/parsing-lexicon 0.8.4 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.8.4",
3
+ "version": "0.9.1",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -197,6 +197,10 @@
197
197
  "types": "./src/semantic-spans.d.ts",
198
198
  "import": "./src/semantic-spans.js"
199
199
  },
200
+ "./address-grammar": {
201
+ "types": "./src/address-grammar.d.ts",
202
+ "import": "./src/address-grammar.js"
203
+ },
200
204
  "./hiring-professions": "./src/hiring-professions.js",
201
205
  "./hiring-profession-display": {
202
206
  "types": "./src/hiring-profession-display.d.ts",
@@ -0,0 +1,13 @@
1
+ export type AddressGrammarLanguage = 'ru' | 'uk' | 'ro' | 'en' | 'kk' | 'ky' | 'uzLatn' | 'uzCyrl';
2
+ export type AddressGrammarMarker = Readonly<{ pattern: string; lang: AddressGrammarLanguage }>;
3
+
4
+ export const STREET_PREFIX_MARKERS: readonly AddressGrammarMarker[];
5
+ export const STREET_POSTFIX_MARKERS: readonly AddressGrammarMarker[];
6
+ export const STREET_TYPE_MARKERS: readonly AddressGrammarMarker[];
7
+ export const HOUSE_MARKERS: readonly AddressGrammarMarker[];
8
+ export const BUILDING_MARKERS: readonly AddressGrammarMarker[];
9
+
10
+ export const COUNTRY_ADDRESS_LANGUAGES: Readonly<Record<string, readonly AddressGrammarLanguage[]>>;
11
+
12
+ export function combinedMarkerPattern(markers: readonly AddressGrammarMarker[]): string;
13
+ export function matchesCountryAddressLanguage(text: unknown, country: unknown, markers: readonly AddressGrammarMarker[]): boolean;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Country/language-scoped address grammar data, extracted from
3
+ * housing-address.js's combined marker regexes. This is data, not logic:
4
+ * housing-address.js still runs one shared parser engine and still builds
5
+ * the exact same combined patterns from these fragments (verified
6
+ * byte-identical to the pre-extraction hardcoded regex source in
7
+ * address-grammar.test.js) — nothing about matching behavior changes.
8
+ *
9
+ * The value of splitting it out is making "which markers belong to which
10
+ * language/country" an explicit, inspectable data structure instead of
11
+ * only implicit in one long alternation, so a caller that already knows
12
+ * the listing's country can use STREET_GRAMMAR_LANGUAGES_BY_COUNTRY as
13
+ * scoring evidence (e.g. "this address's markers are consistent with the
14
+ * supplied country") — a prior, per the plan, never a rigid requirement,
15
+ * since messy marketplace text routinely mixes languages regardless of
16
+ * country.
17
+ */
18
+
19
+ // Each marker fragment is tagged by the language it belongs to (not the
20
+ // country) because that's what's actually true of the text — "ул./улица"
21
+ // is a Russian-language marker that shows up in UZ, KZ, KG and UA listings
22
+ // alike, not a marker exclusive to one country.
23
+ export const STREET_PREFIX_MARKERS = Object.freeze([
24
+ { pattern: 'ул(?:ица)?', lang: 'ru' },
25
+ { pattern: 'вул(?:иця)?', lang: 'uk' },
26
+ { pattern: 'пр', lang: 'ru' },
27
+ { pattern: 'просп(?:ект)?', lang: 'ru' },
28
+ { pattern: 'пр-т', lang: 'ru' },
29
+ { pattern: 'переул(?:ок)?', lang: 'ru' },
30
+ { pattern: 'пров(?:улок)?', lang: 'uk' },
31
+ { pattern: 'проезд', lang: 'ru' },
32
+ { pattern: 'наб(?:ережная)?', lang: 'ru' },
33
+ { pattern: 'шоссе', lang: 'ru' },
34
+ { pattern: "str(?:ada)?", lang: 'ro' },
35
+ { pattern: 'street', lang: 'en' },
36
+ { pattern: 'st', lang: 'en' },
37
+ { pattern: 'avenue', lang: 'en' },
38
+ { pattern: 'ave', lang: 'en' },
39
+ { pattern: 'road', lang: 'en' },
40
+ { pattern: 'rd', lang: 'en' },
41
+ { pattern: 'көше', lang: 'kk' },
42
+ ]);
43
+
44
+ export const STREET_POSTFIX_MARKERS = Object.freeze([
45
+ { pattern: 'ko[\'’ʼ\\u02bc]?cha(?:si)?', lang: 'uzLatn' },
46
+ { pattern: 'кўча(?:си)?', lang: 'uzCyrl' },
47
+ { pattern: 'коча(?:си)?', lang: 'uzCyrl' },
48
+ { pattern: 'kocha(?:si)?', lang: 'uzLatn' },
49
+ { pattern: 'көше(?:сі)?', lang: 'kk' },
50
+ { pattern: 'көшесі', lang: 'kk' },
51
+ { pattern: 'көчө(?:сү)?', lang: 'ky' },
52
+ ]);
53
+
54
+ export const STREET_TYPE_MARKERS = Object.freeze([
55
+ { pattern: 'вулиця', lang: 'uk' },
56
+ { pattern: 'улица', lang: 'ru' },
57
+ { pattern: 'провулок', lang: 'uk' },
58
+ { pattern: 'переулок', lang: 'ru' },
59
+ { pattern: 'проспект', lang: 'ru' },
60
+ { pattern: 'бульвар', lang: 'ru' },
61
+ { pattern: 'набережна', lang: 'uk' },
62
+ { pattern: 'набережная', lang: 'ru' },
63
+ { pattern: 'шосе', lang: 'uk' },
64
+ { pattern: 'шоссе', lang: 'ru' },
65
+ { pattern: 'площа', lang: 'uk' },
66
+ { pattern: 'площадь', lang: 'ru' },
67
+ { pattern: 'узвіз', lang: 'uk' },
68
+ { pattern: 'спуск', lang: 'ru' },
69
+ { pattern: 'алея', lang: 'uk' },
70
+ { pattern: 'аллея', lang: 'ru' },
71
+ { pattern: 'дорога', lang: 'ru' },
72
+ { pattern: 'тупик', lang: 'ru' },
73
+ { pattern: 'көше(?:сі)?', lang: 'kk' },
74
+ { pattern: 'көшесі', lang: 'kk' },
75
+ { pattern: 'көчө(?:сү)?', lang: 'ky' },
76
+ ]);
77
+
78
+ export const HOUSE_MARKERS = Object.freeze([
79
+ { pattern: 'дом', lang: 'ru' },
80
+ { pattern: 'д\\.', lang: 'ru' },
81
+ { pattern: 'будинок', lang: 'uk' },
82
+ { pattern: 'буд\\.', lang: 'uk' },
83
+ { pattern: 'house', lang: 'en' },
84
+ { pattern: 'h\\.', lang: 'en' },
85
+ { pattern: 'uy', lang: 'uzLatn' },
86
+ { pattern: 'уй', lang: 'uzCyrl' },
87
+ { pattern: 'үй', lang: 'kk' },
88
+ { pattern: 'nr\\.?', lang: 'ro' },
89
+ { pattern: 'no\\.?', lang: 'en' },
90
+ { pattern: '№', lang: 'ru' },
91
+ ]);
92
+
93
+ export const BUILDING_MARKERS = Object.freeze([
94
+ { pattern: 'корп(?:ус)?\\.?', lang: 'ru' },
95
+ { pattern: 'к\\.', lang: 'ru' },
96
+ { pattern: 'строен(?:ие)?', lang: 'ru' },
97
+ { pattern: 'стр\\.', lang: 'ru' },
98
+ { pattern: 'будова', lang: 'uk' },
99
+ { pattern: 'секц(?:ия|ія)?', lang: 'ru' },
100
+ { pattern: 'bloc', lang: 'ro' },
101
+ { pattern: 'corp', lang: 'ro' },
102
+ { pattern: 'building', lang: 'en' },
103
+ { pattern: 'bldg\\.?', lang: 'en' },
104
+ { pattern: 'korpus', lang: 'uzLatn' },
105
+ { pattern: 'bino', lang: 'uzLatn' },
106
+ { pattern: 'bina', lang: 'uzLatn' },
107
+ { pattern: 'бино', lang: 'uzCyrl' },
108
+ ]);
109
+
110
+ // Which languages a given priority country's listings commonly mix, in the
111
+ // order they're worth checking first. Deliberately not exhaustive or
112
+ // exclusive — messy marketplace text can and does use any language
113
+ // regardless of country, so this is scoring-prior data, not a filter.
114
+ export const COUNTRY_ADDRESS_LANGUAGES = Object.freeze({
115
+ UZ: Object.freeze(['uzLatn', 'uzCyrl', 'ru']),
116
+ KZ: Object.freeze(['kk', 'ru']),
117
+ KG: Object.freeze(['ky', 'ru']),
118
+ UA: Object.freeze(['uk', 'ru']),
119
+ RO: Object.freeze(['ro']),
120
+ });
121
+
122
+ function joinMarkerGroup(markers) {
123
+ return markers.map((marker) => marker.pattern).join('|');
124
+ }
125
+
126
+ /** Rebuild the exact combined alternation source housing-address.js uses internally, from the tagged marker data above. */
127
+ export function combinedMarkerPattern(markers) {
128
+ return joinMarkerGroup(markers);
129
+ }
130
+
131
+ /** True when `text` contains at least one marker tagged for one of `country`'s priority languages. */
132
+ export function matchesCountryAddressLanguage(text, country, markers) {
133
+ const languages = COUNTRY_ADDRESS_LANGUAGES[String(country || '').toUpperCase()];
134
+ if (!languages?.length) return false;
135
+ const relevant = markers.filter((marker) => languages.includes(marker.lang));
136
+ if (!relevant.length) return false;
137
+ const pattern = new RegExp(`(?:${joinMarkerGroup(relevant)})`, 'iu');
138
+ return pattern.test(String(text || ''));
139
+ }
@@ -5,14 +5,28 @@ import {
5
5
  matchTashkentNumberedArea,
6
6
  } from './tashkent-housing-geography.js';
7
7
  import { detectNonAddressSpans } from './semantic-spans.js';
8
+ import {
9
+ BUILDING_MARKERS,
10
+ HOUSE_MARKERS,
11
+ STREET_POSTFIX_MARKERS,
12
+ STREET_PREFIX_MARKERS,
13
+ STREET_TYPE_MARKERS,
14
+ combinedMarkerPattern,
15
+ } from './address-grammar.js';
8
16
 
9
17
  const PHONE_RUN_RE = /\+?\d[\d\s().-]{7,}\d/gu;
10
18
  const ADDRESS_LABEL_RE = /(?:адрес|адреса|адресація|адресация|manzil|address|adresă|adresa)\s*[:=\-–—]\s*/iu;
11
- const PREFIX_STREET_MARKER = String.raw`(?:(?:ул(?:ица)?|вул(?:иця)?|пр|просп(?:ект)?|пр-т|переул(?:ок)?|пров(?:улок)?|проезд|наб(?:ережная)?|шоссе|str(?:ada)?|street|st|avenue|ave|road|rd|көше)\.?)`;
12
- const POSTFIX_STREET_MARKER = String.raw`(?:ko['’ʼ\u02bc]?cha(?:si)?|кўча(?:си)?|коча(?:си)?|kocha(?:si)?|көше(?:сі)?|көшесі|көчө(?:сү)?)`;
13
- const POSTFIX_STREET_TYPE = String.raw`(?:вулиця|улица|провулок|переулок|проспект|бульвар|набережна|набережная|шосе|шоссе|площа|площадь|узвіз|спуск|алея|аллея|дорога|тупик|көше(?:сі)?|көшесі|көчө(?:сү)?)`;
14
- const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.|uy|уй|үй|nr\.?|no\.?|№)`;
15
- const BUILDING_MARKER = String.raw`(?:корп(?:ус)?\.?|к\.|строен(?:ие)?|стр\.|будова|секц(?:ия|ія)?|bloc|corp|building|bldg\.?|korpus|bino|bina|бино)`;
19
+ // These marker alternations are built from address-grammar.js's tagged,
20
+ // per-language fragment data (one shared parser engine, country/language
21
+ // grammar as data — see that file) rather than hardcoded here. The
22
+ // combined pattern strings are verified byte-identical to the original
23
+ // hardcoded regexes in address-grammar.test.js, so this is a pure data
24
+ // extraction with no behavior change.
25
+ const PREFIX_STREET_MARKER = String.raw`(?:(?:${combinedMarkerPattern(STREET_PREFIX_MARKERS)})\.?)`;
26
+ const POSTFIX_STREET_MARKER = `(?:${combinedMarkerPattern(STREET_POSTFIX_MARKERS)})`;
27
+ const POSTFIX_STREET_TYPE = `(?:${combinedMarkerPattern(STREET_TYPE_MARKERS)})`;
28
+ const HOUSE_MARKER = `(?:${combinedMarkerPattern(HOUSE_MARKERS)})`;
29
+ const BUILDING_MARKER = `(?:${combinedMarkerPattern(BUILDING_MARKERS)})`;
16
30
  const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}]\d{0,4})?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}]\d{0,4})?){0,2}`;
17
31
  const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
18
32
  // Common post-Soviet street names lead with a bare numeral ("8 Марта",
@@ -10,6 +10,7 @@ import {
10
10
  import { maskPhoneLikeSpans } from './contact.js';
11
11
  import { DEPOSIT_TERMS, SELLER_TERMS } from './housing.js';
12
12
  import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
13
+ import { extractTemporalCandidates } from './temporal.js';
13
14
  import {
14
15
  classifyHousingSingleMSpans,
15
16
  HOUSING_NUMERIC_SPAN_TYPES,
@@ -94,6 +95,21 @@ function parsedMoneyAmount(numberValue, scaleValue) {
94
95
 
95
96
  const APPROXIMATE_RE = /около|примерно|~|≈/iu;
96
97
 
98
+ // A number already claimed by a duration/date/schedule reading ("на 1200
99
+ // дней", "с 2027 года") is not a plausible bare price — it is at least as
100
+ // likely to be that temporal value's own count/year as a price digit that
101
+ // happens to sit near it. This is only used as a last-resort exclusion in
102
+ // the bare-amount fallback below, once every explicit currency/scale/
103
+ // keyword path has already failed to find a price. Only reasonably
104
+ // confident temporal candidates disqualify a span, so an ambiguous bare
105
+ // number elsewhere in the text is unaffected. Calls temporal.js directly
106
+ // rather than semantic-spans.js: that module itself depends on this file's
107
+ // extractHousingMoneyCandidates(), so importing it here would cycle back.
108
+ function overlapsConfidentTemporalSpan(text, start, end) {
109
+ return extractTemporalCandidates(text).some((item) => (Number(item.confidence) || 0) >= 0.5
110
+ && start < item.end && item.start < end);
111
+ }
112
+
97
113
  function perSquareMeterMatches(text, fallbackCurrency = '') {
98
114
  const matches = [];
99
115
  const seen = new Set();
@@ -561,6 +577,10 @@ export function parseHousingPrice(value, context = '') {
561
577
  const hasPriceEvidence = PRICE_KEYWORD_RE.test(window) || Boolean(moneyCurrencyFromText(window, ''));
562
578
  if (!hasPriceEvidence) continue;
563
579
  }
580
+ // A number already read as a duration/date/schedule value ("\u043D\u0430 1200
581
+ // \u0434\u043D\u0435\u0439") is not a plausible bare price either \u2014 e.g. "\u0441\u0434\u0430\u044E \u043D\u0430 1200
582
+ // \u0434\u043D\u0435\u0439" must not report 1200 as the rent.
583
+ if (overlapsConfidentTemporalSpan(priceText, start, start + raw.length)) continue;
564
584
  if (best == null || amount > best) best = amount;
565
585
  }
566
586
  price = best;
@@ -0,0 +1,72 @@
1
+ import { HIRING_MONTHS } from './hiring-temporal.js';
2
+ import {
3
+ candidate,
4
+ dateEntityType,
5
+ dateValue,
6
+ inferYear,
7
+ inferredDateEvidence,
8
+ referenceDate,
9
+ referenceEntry,
10
+ relationNear,
11
+ temporalContextType,
12
+ validDate,
13
+ } from './temporal-shared.js';
14
+
15
+ /**
16
+ * CalendarDateParser: absolute calendar dates — numeric ("12.01.2027",
17
+ * "2027-01-12"), month-name ("12 января" / "January 12"), and "end of
18
+ * <month>" phrasing. Produces calendarDate/availabilityDate/
19
+ * availabilityUntil/deadline/publicationDate candidates depending on
20
+ * nearby from/until wording and domain context (see dateEntityType).
21
+ */
22
+
23
+ const MONTH_NAMES = Object.freeze(Object.keys(HIRING_MONTHS).sort((a, b) => b.length - a.length).join('|'));
24
+ const DATE_WORD_RE = new RegExp(`(?<![\\p{L}\\p{N}])(\\d{1,2})\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
25
+ const DATE_MONTH_FIRST_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${MONTH_NAMES})\\s+(\\d{1,2})(?:,?\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
26
+ const DATE_NUMERIC_RE = /(?<!\d)(?:(20\d{2})-(\d{1,2})-(\d{1,2})|(\d{1,2})[./](\d{1,2})[./](20\d{2}))(?!\d)/u;
27
+ const DATE_NUMERIC_PARTIAL_RE = /(?<![\d.])(\d{1,2})[./](\d{1,2})(?![\d.])/u;
28
+ const END_OF_MONTH_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:до|until|p[âa]nă\\s+la|gacha|дейін|чейин|available\\s+until)\\s+(?:конца|кінця|sf[âa]rșit(?:ul)?(?:\\s+lunii)?|end\\s+of)\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'giu');
29
+ const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап)\s+(?:начала\s+месяца|початку\s+місяця|începutul\s+lunii|start\s+of\s+(?:the\s+)?month|1(?:-го)?\s+числа|первого\s+числа|1(?:-го)?\s+числа\s+місяця)(?![\p{L}\p{N}])/giu;
30
+ const NEXT_MONTH_RE = /(?<![\p{L}\p{N}])(?:next\s+month|со?\s+следующего\s+месяца|з\s+наступного\s+місяця)(?![\p{L}\p{N}])/giu;
31
+
32
+ function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
33
+ function dateAtNextMonthStart(reference) { const month = reference.getUTCMonth() + 2; const year = reference.getUTCFullYear() + Math.floor((month - 1) / 12); return dateValue(year, ((month - 1) % 12) + 1, 1); }
34
+
35
+ export function extractCalendarDateCandidates(text, context = {}) {
36
+ const candidates = [];
37
+ for (const match of text.matchAll(new RegExp(DATE_NUMERIC_RE, 'gu'))) {
38
+ const date = match[1] ? dateValue(Number(match[1]), Number(match[2]), Number(match[3])) : dateValue(Number(match[6]), Number(match[5]), Number(match[4]));
39
+ if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context), date, match, 'temporal.calendar.numeric', .98, [{ type: 'regex', rule: 'numeric-date' }]));
40
+ }
41
+ for (const match of text.matchAll(new RegExp(DATE_NUMERIC_PARTIAL_RE, 'gu'))) {
42
+ const start = match.index ?? 0; const around = text.slice(Math.max(0, start - 40), start + match[0].length + 40);
43
+ if (!/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?|свобод|доступ|заезд|заезж|available|move[- ]?in|(?<![\p{L}\p{N}])(?:с|до|from|until)(?![\p{L}\p{N}]))/iu.test(around)) continue;
44
+ const relation = relationNear(text, start); const inferred = true;
45
+ const date = dateValue(inferYear(Number(match[2]), Number(match[1]), relation, context), Number(match[2]), Number(match[1]));
46
+ if (validDate(date)) candidates.push(candidate(dateEntityType(text, start, context, relation), date, match, 'temporal.calendar.numeric-partial', .86, [{ type: 'regex', rule: 'numeric-partial-date' }, ...inferredDateEvidence(inferred, context)]));
47
+ }
48
+ for (const match of text.matchAll(new RegExp(DATE_WORD_RE, 'giu'))) {
49
+ const monthIndex = HIRING_MONTHS[match[2].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
50
+ const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[1]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[1]));
51
+ if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-name', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[2] }, ...inferredDateEvidence(inferred, context)]));
52
+ }
53
+ for (const match of text.matchAll(new RegExp(DATE_MONTH_FIRST_RE, 'giu'))) {
54
+ const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
55
+ const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[2]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[2]));
56
+ if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-first', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, ...inferredDateEvidence(inferred, context)]));
57
+ }
58
+ for (const match of text.matchAll(END_OF_MONTH_RE)) {
59
+ const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
60
+ const inferred = !match[2]; const year = inferred ? inferYear(monthIndex + 1, 1, 'until', context) : Number(match[2]);
61
+ candidates.push(candidate('availabilityUntil', dateAtMonthEnd(year, monthIndex + 1), match, 'temporal.calendar.month-end', inferred ? .86 : .94, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, { type: 'relation', value: 'until-month-end' }, ...inferredDateEvidence(inferred, context)]));
62
+ }
63
+ for (const match of text.matchAll(START_OF_MONTH_RE)) {
64
+ const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
65
+ candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.month-start', .88, [{ type: 'context', value: 'month-start' }, { type: 'reference-date', value: referenceEntry(context).source }]));
66
+ }
67
+ for (const match of text.matchAll(NEXT_MONTH_RE)) {
68
+ const date = dateAtNextMonthStart(referenceDate(context));
69
+ candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.next-month', .88, [{ type: 'context', value: 'next-month' }, { type: 'reference-date', value: referenceEntry(context).source }]));
70
+ }
71
+ return candidates;
72
+ }
@@ -0,0 +1,46 @@
1
+ import { SCHEDULE_CONTEXT_RE } from './temporal-schedule.js';
2
+ import { candidate } from './temporal-shared.js';
3
+
4
+ /**
5
+ * ClockTimeParser + TimeRangeRefiner: a time-of-day ("07:00", "7 утра")
6
+ * and time ranges ("с 07.00 до 19.00", "22:00-06:00", detecting an
7
+ * overnight range that crosses midnight). A bare clock time is only kept
8
+ * when it carries an am/pm-style suffix or nearby schedule-context
9
+ * wording — otherwise a bare "7" is at least as likely to be a price or
10
+ * an unrelated number, so it stays unresolved.
11
+ */
12
+
13
+ export const TIME_SUFFIX = String.raw`(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)`;
14
+ const TIME_RE = new RegExp(String.raw`(?<![\d.:])(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?(?![\d.:])`, 'iu');
15
+ const TIME_RANGE_RE = new RegExp(String.raw`(?:с|з|from|dan)?\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)\s*(?:до|to|gacha|дейін|чейин|-|–|—)\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)`, 'iu');
16
+
17
+ export function parseClock(raw) { const match = String(raw).trim().match(new RegExp(String.raw`^(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?$`, 'iu')); if (!match) return null; let hour = Number(match[1]); const minute = Number(match[2] || 0); const suffix = String(match[3] || '').toLowerCase(); if (suffix === 'pm' && hour < 12) hour += 12; if (suffix === 'am' && hour === 12) hour = 0; if (/(?:вечера|вечора|kechqurun)/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
18
+
19
+ function isTimeRangeContextual(match, text) {
20
+ if (/[.:]|\b(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)\b/iu.test(match[0])) return true;
21
+ if (/^\s*(?:с|from)\b/iu.test(match[0])) return true;
22
+ const start = match.index ?? 0;
23
+ return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
24
+ }
25
+ function isClockContextual(match, text) {
26
+ if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
27
+ const start = match.index ?? 0;
28
+ return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
29
+ }
30
+
31
+ export function extractClockCandidates(text) {
32
+ const candidates = [];
33
+ const timeRangeSpans = [];
34
+ for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
35
+ const start = parseClock(match[1]); const end = parseClock(match[2]);
36
+ if (!start || !end || !isTimeRangeContextual(match, text)) continue;
37
+ timeRangeSpans.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
38
+ candidates.push(candidate('timeRange', Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }), match, 'temporal.time-range', .98, [{ type: 'range', value: 'clock-time' }]));
39
+ }
40
+ for (const match of text.matchAll(new RegExp(TIME_RE, 'giu'))) {
41
+ const start = match.index ?? 0; const end = start + match[0].length;
42
+ if (timeRangeSpans.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd) || !isClockContextual(match, text)) continue;
43
+ const clock = parseClock(match[0]); if (clock) candidates.push(candidate('clockTime', clock, match, 'temporal.clock-time', .94, [{ type: 'clock', value: 'explicit' }]));
44
+ }
45
+ return candidates;
46
+ }
@@ -0,0 +1,44 @@
1
+ import { candidate } from './temporal-shared.js';
2
+
3
+ /**
4
+ * DurationParser: a length of time with an optional bound (min/max/exact)
5
+ * — "3 месяца", "от 3х мес.", "минимум 3 месяца", "на полгода". Semantic
6
+ * purpose (rental duration vs probation vs contract vs generic) is
7
+ * resolved by semanticDurationType from surrounding context, not by the
8
+ * raw duration reading itself.
9
+ */
10
+
11
+ const DURATION_PREFIX = String.raw`(?:от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш(?:е)?|at\s+least|до|не\s+более|ko'?pi\s+bilan|көп\s+емес|на|uchun|pe\s+o\s+perioadă\s+de)`;
12
+ const DURATION_UNIT = String.raw`(?:полгода|год(?:а|ов)?|yil(?:ga)?|жыл(?:ға)?|рок(?:и|ів)?|an(?:i)?|месяц(?:а|ев)?|мес\.?|oy(?:ga)?|місяц(?:і|ів|я)?|luni?|недел[ьяи]|hafta(?:ga)?|апта(?:ға)?|тиж(?:день|ні|нів|ня)|săptămân\p{L}*|saptaman\p{L}*|дн(?:я|ей)?|день|kun(?:ga)?|күн(?:ге)?|день|днів|дні|zi(?:le)?|час(?:а|ов)?|soat(?:ga)?|сағат(?:қа)?|годин(?:а|и|у)?|ore?|мин(?:ут[аы]?|\.)?|daqiqa|минут\p{L}*|minute?)`;
13
+ const DURATION_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(` + DURATION_UNIT + String.raw`)(?:\s*)?(\d+(?:[.,]\d+)?)?(?![\p{L}\p{N}])`, 'iu');
14
+ const DURATION_NUMBER_FIRST_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(\d+(?:[.,]\d+)?)(?:-?х)?\s*(` + DURATION_UNIT + String.raw`)(?![\p{L}\p{N}])`, 'iu');
15
+
16
+ function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year|yil|жыл|рок|\ban/i.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month|\boy|місяц|lun/i.test(normalized) ? 'month' : /нед|week|hafta|апта|тиж|săptăm|saptaman/i.test(normalized) ? 'week' : /дн|день|day|\bkun|күн|zi/i.test(normalized) ? 'day' : /мин|minute|daqiqa/i.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш|at\s+least/iu.test(prefix || '') ? 'min' : /до|не\s+более|ko'?pi\s+bilan|көп\s+емес/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
17
+
18
+ function semanticDurationType(text, start, duration) {
19
+ const before = text.slice(Math.max(0, start - 56), start);
20
+ const after = text.slice(start, Math.min(text.length, start + 56));
21
+ if (/(?:испытательн(?:ый)?\s+срок|probation)[^.;\n]{0,30}$/iu.test(before)) return 'probationDuration';
22
+ if (/(?:контракт|contract)[^.;\n]{0,30}$/iu.test(before)) return 'contractDuration';
23
+ if (/(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)/iu.test(after)) {
24
+ if (duration?.bound === 'max') return 'maximumRentalDuration';
25
+ if (duration?.bound === 'exact') return 'fixedRentalDuration';
26
+ return 'minimumRentalDuration';
27
+ }
28
+ return 'duration';
29
+ }
30
+
31
+ export function extractDurationCandidates(text) {
32
+ const candidates = [];
33
+ for (const match of text.matchAll(new RegExp(DURATION_NUMBER_FIRST_RE, 'giu'))) { const value = durationValue(match[1], match[2], match[3]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
34
+ for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
35
+ if (match[3]) continue;
36
+ // Bare genitive "дня" is almost always part of another phrase ("конца
37
+ // дня", "сегодняшнего дня", "через 2 дня") rather than a standalone
38
+ // 1-day duration. Only accept it here when an explicit duration prefix
39
+ // ("на", "минимум", ...) makes the duration reading unambiguous.
40
+ if (!match[1] && /^дня$/iu.test(match[2])) continue;
41
+ const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])]));
42
+ }
43
+ return candidates;
44
+ }
@@ -0,0 +1,38 @@
1
+ import { nextWeekday, DAY_ALIASES, DAY_PATTERN } from './temporal-weekday.js';
2
+ import { addUtcDays, candidate, referenceDate, referenceEntry, temporalContextType } from './temporal-shared.js';
3
+
4
+ /**
5
+ * RelativeDateParser: dates expressed relative to the reference date —
6
+ * "сегодня"/"завтра"/"через 3 дня", and "next <weekday>". Semantic role
7
+ * (availabilityDate/startDate/plain relativeDate) is assigned by
8
+ * temporalContextType based on nearby wording, not by this parser.
9
+ */
10
+
11
+ const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(послезавтрашн\p{L}*\s+дн\p{L}*|сегодняшн\p{L}*\s+дн\p{L}*|завтрашн\p{L}*\s+дн\p{L}*|післязавтрашн\p{L}*\s+дн\p{L}*|сьогоднішн\p{L}*\s+дн\p{L}*|сегодня|завтра|послезавтра|сьогодні|післязавтра|bugun|ertaga|indin|бүгін|ертең|бүрсігүні|бүгүн|эртең|бүрсүгүнү|astăzi|azi|mâine|maine|poimâine|poimaine|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
12
+ const EXTENDED_RELATIVE_RE = /(?<![\p{L}\p{N}])(?:через\s+(\d+)\s+(дні|днів|тижд(?:ень|ні|нів|ня))|peste\s+(\d+)\s+(zile?|săptămân(?:ă|i)|saptaman(?:a|i))|(\d+)\s+(күннен|аптадан|кун(?:дөн|дон)|жумадан)\s+(?:кейін|кийин))(?![\p{L}\p{N}])/giu;
13
+ const NEXT_WEEKDAY_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:со?\\s+следующ(?:его|ей)\\s+|з\\s+наступн(?:ого|ої)\\s+|next\\s+)(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
14
+
15
+ function relativeDays(raw) { const lower = raw.toLowerCase(); if (/сегодня|сьогодні|bugun|бүгін|бүгүн|astăzi|azi|today/u.test(lower)) return 0; if (/послезавтра|післязавтра|indin|бүрсігүні|бүрсүгүнү|poimâine|poimaine|day\s+after/u.test(lower)) return 2; if (/завтра|ertaga|ертең|эртең|mâine|maine|tomorrow/u.test(lower)) return 1; if (/две\s+недели/u.test(lower)) return 14; if (/неделю/u.test(lower)) return 7; const numeric = Number(lower.match(/\d+/u)?.[0]); return Number.isFinite(numeric) ? numeric : null; }
16
+ function relativeDurationDays(amount, unit) {
17
+ const value = Number(amount);
18
+ if (!Number.isFinite(value) || value < 0) return null;
19
+ return /(?:тиж|săptăm|saptaman|апта|жума)/iu.test(unit) ? value * 7 : value;
20
+ }
21
+
22
+ export function extractRelativeDateCandidates(text, context = {}) {
23
+ const candidates = [];
24
+ for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
25
+ const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
26
+ candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), nextWeekday(referenceDate(context), weekday), match, 'temporal.relative.next-weekday', .9, [{ type: 'dictionary', dictionary: 'weekdays', key: match[1] }, { type: 'reference-date', value: referenceEntry(context).source }]));
27
+ }
28
+ for (const match of text.matchAll(RELATIVE_RE)) { const days = relativeDays(match[1]); if (days == null) continue; const type = temporalContextType(text, match.index ?? 0, context); candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'reference-date', value: referenceEntry(context).source }])); }
29
+ for (const match of text.matchAll(EXTENDED_RELATIVE_RE)) {
30
+ const amount = match[1] || match[3] || match[5];
31
+ const unit = match[2] || match[4] || match[6];
32
+ const days = relativeDurationDays(amount, unit);
33
+ if (days == null) continue;
34
+ const type = temporalContextType(text, match.index ?? 0, context);
35
+ candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date.extended', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'unit', value: unit }, { type: 'reference-date', value: referenceEntry(context).source }]));
36
+ }
37
+ return candidates;
38
+ }
@@ -0,0 +1,65 @@
1
+ import { DAY_ALIASES, DAY_PATTERN, WEEKDAYS } from './temporal-weekday.js';
2
+ import { candidate } from './temporal-shared.js';
3
+
4
+ /**
5
+ * ScheduleCycleParser + WeekdayParser + multiple-shifts: work-schedule
6
+ * cycles ("2/2", "два через два"), fixed weekday ranges ("Пн-Пт"), named
7
+ * weekday sets ("по будням"/"только по выходным"), and multi-shift
8
+ * listings ("1 смена 07:00-15:00 ... 2 смена ..."). A bare "N/M" ratio is
9
+ * only read as a schedule when SCHEDULE_CONTEXT_RE finds nearby work-
10
+ * schedule language — otherwise it stays ambiguous (never assumed to be a
11
+ * schedule by default).
12
+ */
13
+
14
+ export const SCHEDULE_CONTEXT_RE = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job|графік|змін[аи]|program(?:ul)?\s+de\s+lucru|ish\s+grafigi|жұмыс\s+кестесі|жумуш\s+графиги)/iu;
15
+ const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
16
+ const SHIFT_RE = /(?<!\d)(\d{1,2})\s*(?:смен[аы]|shift)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:-|–|—|до|to)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/giu;
17
+
18
+ function scheduleDaysOffMode(text) {
19
+ if (/(?:плавающ|floating|flexible\s+days\s+off)/iu.test(text)) return 'floating';
20
+ if (/(?:скользящ|сменн|rotating\s+days\s+off|выходные\s+по\s+графику)/iu.test(text)) return 'rotating';
21
+ return 'fixed';
22
+ }
23
+ function cycleScheduleValue(work, rest, daysOffMode) {
24
+ // Day cycles such as 2/2 and 5/2 are common. Values larger than a week in
25
+ // an explicit schedule context are the conventional shift notation 24/48
26
+ // or 12/24, not a claim of dozens of working days.
27
+ if (Math.max(work, rest) > 7) {
28
+ // An hour cycle has no fixed weekday rest days. Keep an explicitly
29
+ // floating mode, otherwise expose the inherent repeating rotation.
30
+ return Object.freeze({ type: 'cycle', cycleHours: Object.freeze({ work, rest }), daysOffMode: daysOffMode === 'fixed' ? 'rotating' : daysOffMode });
31
+ }
32
+ return Object.freeze({ type: 'cycle', workDays: work, restDays: rest, daysOffMode });
33
+ }
34
+
35
+ export function extractScheduleCandidates(text, parseClock) {
36
+ const candidates = [];
37
+ const scheduleContext = SCHEDULE_CONTEXT_RE;
38
+ for (const match of text.matchAll(/(?<![\d:.])(\d{1,2})\s*(?:\/|\\|через|-)\s*(\d{1,2})(?![\d:.])/giu)) {
39
+ const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
40
+ const daysOffMode = scheduleDaysOffMode(around);
41
+ const work = Number(match[1]); const rest = Number(match[2]);
42
+ candidates.push(candidate('workSchedule', cycleScheduleValue(work, rest, daysOffMode), match, 'temporal.schedule-cycle', .99, [{ type: 'context', value: 'schedule' }, { type: 'range', value: Math.max(work, rest) > 7 ? 'cycle-hours' : 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
43
+ }
44
+ for (const match of text.matchAll(/(?<![\p{L}\p{N}])два\s+через\s+два(?![\p{L}\p{N}])/giu)) {
45
+ const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
46
+ const daysOffMode = scheduleDaysOffMode(around);
47
+ candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: 2, restDays: 2, daysOffMode }), match, 'temporal.schedule-cycle.words', .97, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
48
+ }
49
+ for (const match of text.matchAll(WEEKDAY_RANGE_RE)) {
50
+ const start = DAY_ALIASES[match[1].toLowerCase()]; const end = DAY_ALIASES[match[2].toLowerCase()];
51
+ if (start == null || end == null || end < start) continue;
52
+ candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(start, end + 1), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays', .97, [{ type: 'range', value: 'weekday' }]));
53
+ }
54
+ for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:по\s+будням|weekdays?)(?![\p{L}\p{N}])/giu)) {
55
+ candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(0, 5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays.named', .96, [{ type: 'context', value: 'weekdays' }]));
56
+ }
57
+ for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:только\s+по\s+выходным|weekends?\s+only)(?![\p{L}\p{N}])/giu)) {
58
+ candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekends', .96, [{ type: 'context', value: 'weekends' }]));
59
+ }
60
+ const shifts = [];
61
+ let firstShift = null; let lastShift = null;
62
+ for (const match of text.matchAll(SHIFT_RE)) { const start = parseClock(match[2]); const end = parseClock(match[3]); if (!start || !end) continue; firstShift ||= match; lastShift = match; shifts.push(Object.freeze({ name: `${match[1]} shift`, hours: Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }) })); }
63
+ if (shifts.length) candidates.push(candidate('shifts', Object.freeze(shifts), { 0: text.slice(firstShift.index, (lastShift.index ?? 0) + lastShift[0].length), index: firstShift.index }, 'temporal.multiple-shifts', .98, [{ type: 'context', value: 'shift' }, { type: 'range', value: 'clock-time' }]));
64
+ return candidates;
65
+ }
@@ -0,0 +1,54 @@
1
+ import { createParseCandidate } from './parser-core.js';
2
+
3
+ /**
4
+ * Helpers shared by every temporal sub-parser (calendar dates, relative
5
+ * dates, durations, clock/schedule). Kept in one place so reference-date
6
+ * resolution, candidate construction and from/until relation detection
7
+ * cannot drift between parsers.
8
+ */
9
+
10
+ export function referenceEntry(context = {}) {
11
+ for (const [source, value] of [['referenceDate', context.referenceDate], ['publishedAt', context.publishedAt], ['fetchedAt', context.fetchedAt]]) {
12
+ if (value == null || value === '') continue;
13
+ const date = new Date(value);
14
+ if (Number.isFinite(date.getTime())) return Object.freeze({ date, source });
15
+ }
16
+ return Object.freeze({ date: new Date(), source: 'currentDate' });
17
+ }
18
+ export function referenceDate(context = {}) { return referenceEntry(context).date; }
19
+ export function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
20
+ export function validDate(value) { const date = new Date(Date.UTC(value.year, value.month - 1, value.day)); return date.getUTCFullYear() === value.year && date.getUTCMonth() === value.month - 1 && date.getUTCDate() === value.day; }
21
+ export function candidate(entityType, value, match, parser, confidence, evidence) { const start = match.index ?? 0; return createParseCandidate({ id: `${entityType}:${start}`, entityType, value, raw: match[0], start, end: start + match[0].length, parser, confidence, evidence }); }
22
+
23
+ // Bare "с"/"з"/"до" are single Cyrillic letters and match as a substring of
24
+ // countless ordinary words ("Сдаю", "доступна") without a boundary guard —
25
+ // "Сдаю до 15.03" was misread as 'from' off the "С" in "Сдаю" alone, hiding
26
+ // the actual "до" (until) marker that followed.
27
+ const RELATION_FROM_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))(?![\p{L}\p{N}])/iu;
28
+ const RELATION_UNTIL_RE = /(?<![\p{L}\p{N}])(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)(?![\p{L}\p{N}])/iu;
29
+ export function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return RELATION_FROM_RE.test(before) ? 'from' : RELATION_UNTIL_RE.test(before) ? 'until' : 'exact'; }
30
+
31
+ // A year-less date is assumed to refer to its next occurrence when the
32
+ // current year's reading has already passed — not just for explicit
33
+ // "from"/"until" wording. A bare mention with no relation ('exact') is the
34
+ // common case (e.g. "12 января встреча") and would otherwise silently
35
+ // resolve to a date up to a year in the past.
36
+ export function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
37
+
38
+ export function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: referenceEntry(context).source }] : []; }
39
+
40
+ export function dateEntityType(text, start, context, relation = relationNear(text, start)) {
41
+ const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48));
42
+ if (/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)/iu.test(around)) return 'deadline';
43
+ if (/(?:published|publication|опубликован|опублікован)/iu.test(around)) return 'publicationDate';
44
+ // Direction must belong to this date's local left context. Looking ahead
45
+ // across a whole listing makes "available from 12 February, until March"
46
+ // incorrectly classify the February date as an end date.
47
+ if (relation === 'until') return 'availabilityUntil';
48
+ if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|вільн|disponibil|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
49
+ return 'calendarDate';
50
+ }
51
+
52
+ export function temporalContextType(text, start, context) { const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48)); if (/(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|бос|бош|вільн|disponibil|move[- ]?in|available)/iu.test(around)) return 'availabilityDate'; if ((context.domain === 'vacancy' || /(?:ваканс|работ|job|ish\s+grafigi|графік)/iu.test(around)) && /(?:выход|start|приступ|boshlash)/iu.test(around)) return 'startDate'; return 'relativeDate'; }
53
+
54
+ export function addUtcDays(date, days) { const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); return dateValue(copy.getUTCFullYear(), copy.getUTCMonth() + 1, copy.getUTCDate()); }
@@ -0,0 +1,17 @@
1
+ import { addUtcDays } from './temporal-shared.js';
2
+
3
+ /** Weekday vocabulary shared by RelativeDateParser (next-weekday) and ScheduleCycleParser (weekday ranges). */
4
+
5
+ export const WEEKDAYS = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
6
+ export const DAY_ALIASES = Object.freeze({
7
+ пн: 0, понедельник: 0, понедельника: 0, понеділок: 0, понеділка: 0, mon: 0, monday: 0, dushanba: 0, дүйсенбі: 0, дүйшөмбү: 0, luni: 0,
8
+ вт: 1, вторник: 1, вторника: 1, вівторок: 1, вівторка: 1, tue: 1, tuesday: 1, seshanba: 1, сейсенбі: 1, шейшемби: 1, marți: 1, marti: 1,
9
+ ср: 2, среда: 2, среды: 2, середа: 2, середи: 2, wed: 2, wednesday: 2, chorshanba: 2, сәрсенбі: 2, шаршемби: 2, miercuri: 2,
10
+ чт: 3, четверг: 3, четверга: 3, четвер: 3, четверга: 3, thu: 3, thursday: 3, payshanba: 3, бейсенбі: 3, бейшемби: 3, joi: 3,
11
+ пт: 4, пятница: 4, пятницы: 4, "п'ятниця": 4, 'п’ятниця': 4, пятниця: 4, fri: 4, friday: 4, juma: 4, жұма: 4, жума: 4, vineri: 4,
12
+ сб: 5, суббота: 5, субботы: 5, субота: 5, sat: 5, saturday: 5, shanba: 5, сенбі: 5, ишемби: 5, sâmbătă: 5, sambata: 5,
13
+ вс: 6, воскресенье: 6, воскресенья: 6, неділя: 6, неділю: 6, sun: 6, sunday: 6, yakshanba: 6, жексенбі: 6, жекшемби: 6, duminică: 6, duminica: 6,
14
+ });
15
+ export const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
16
+
17
+ export function nextWeekday(date, weekday) { const current = (date.getUTCDay() + 6) % 7; let days = (weekday - current + 7) % 7; if (days === 0) days = 7; return addUtcDays(date, days); }
package/src/temporal.js CHANGED
@@ -1,229 +1,32 @@
1
- import { HIRING_MONTHS } from './hiring-temporal.js';
2
- import { createParseCandidate, resolveParseCandidates } from './parser-core.js';
1
+ import { resolveParseCandidates } from './parser-core.js';
2
+ import { extractCalendarDateCandidates } from './temporal-calendar-date.js';
3
+ import { extractRelativeDateCandidates } from './temporal-relative-date.js';
4
+ import { extractDurationCandidates } from './temporal-duration.js';
5
+ import { extractClockCandidates, parseClock } from './temporal-clock.js';
6
+ import { extractScheduleCandidates } from './temporal-schedule.js';
3
7
 
4
- const MONTH_NAMES = Object.freeze(Object.keys(HIRING_MONTHS).sort((a, b) => b.length - a.length).join('|'));
5
- const DATE_WORD_RE = new RegExp(`(?<![\\p{L}\\p{N}])(\\d{1,2})\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
6
- const DATE_MONTH_FIRST_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${MONTH_NAMES})\\s+(\\d{1,2})(?:,?\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
7
- const DATE_NUMERIC_RE = /(?<!\d)(?:(20\d{2})-(\d{1,2})-(\d{1,2})|(\d{1,2})[./](\d{1,2})[./](20\d{2}))(?!\d)/u;
8
- const DATE_NUMERIC_PARTIAL_RE = /(?<![\d.])(\d{1,2})[./](\d{1,2})(?![\d.])/u;
9
- const TIME_SUFFIX = String.raw`(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)`;
10
- const TIME_RE = new RegExp(String.raw`(?<![\d.:])(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?(?![\d.:])`, 'iu');
11
- const TIME_RANGE_RE = new RegExp(String.raw`(?:с|з|from|dan)?\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)\s*(?:до|to|gacha|дейін|чейин|-|–|—)\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)`, 'iu');
12
- const DURATION_PREFIX = String.raw`(?:от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш(?:е)?|at\s+least|до|не\s+более|ko'?pi\s+bilan|көп\s+емес|на|uchun|pe\s+o\s+perioadă\s+de)`;
13
- const DURATION_UNIT = String.raw`(?:полгода|год(?:а|ов)?|yil(?:ga)?|жыл(?:ға)?|рок(?:и|ів)?|an(?:i)?|месяц(?:а|ев)?|мес\.?|oy(?:ga)?|місяц(?:і|ів|я)?|luni?|недел[ьяи]|hafta(?:ga)?|апта(?:ға)?|тиж(?:день|ні|нів|ня)|săptămân\p{L}*|saptaman\p{L}*|дн(?:я|ей)?|день|kun(?:ga)?|күн(?:ге)?|день|днів|дні|zi(?:le)?|час(?:а|ов)?|soat(?:ga)?|сағат(?:қа)?|годин(?:а|и|у)?|ore?|мин(?:ут[аы]?|\.)?|daqiqa|минут\p{L}*|minute?)`;
14
- const DURATION_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(` + DURATION_UNIT + String.raw`)(?:\s*)?(\d+(?:[.,]\d+)?)?(?![\p{L}\p{N}])`, 'iu');
15
- const DURATION_NUMBER_FIRST_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(\d+(?:[.,]\d+)?)(?:-?х)?\s*(` + DURATION_UNIT + String.raw`)(?![\p{L}\p{N}])`, 'iu');
16
- const WEEKDAYS = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
17
- const DAY_ALIASES = Object.freeze({
18
- пн: 0, понедельник: 0, понедельника: 0, понеділок: 0, понеділка: 0, mon: 0, monday: 0, dushanba: 0, дүйсенбі: 0, дүйшөмбү: 0, luni: 0,
19
- вт: 1, вторник: 1, вторника: 1, вівторок: 1, вівторка: 1, tue: 1, tuesday: 1, seshanba: 1, сейсенбі: 1, шейшемби: 1, marți: 1, marti: 1,
20
- ср: 2, среда: 2, среды: 2, середа: 2, середи: 2, wed: 2, wednesday: 2, chorshanba: 2, сәрсенбі: 2, шаршемби: 2, miercuri: 2,
21
- чт: 3, четверг: 3, четверга: 3, четвер: 3, четверга: 3, thu: 3, thursday: 3, payshanba: 3, бейсенбі: 3, бейшемби: 3, joi: 3,
22
- пт: 4, пятница: 4, пятницы: 4, "п'ятниця": 4, 'п’ятниця': 4, пятниця: 4, fri: 4, friday: 4, juma: 4, жұма: 4, жума: 4, vineri: 4,
23
- сб: 5, суббота: 5, субботы: 5, субота: 5, sat: 5, saturday: 5, shanba: 5, сенбі: 5, ишемби: 5, sâmbătă: 5, sambata: 5,
24
- вс: 6, воскресенье: 6, воскресенья: 6, неділя: 6, неділю: 6, sun: 6, sunday: 6, yakshanba: 6, жексенбі: 6, жекшемби: 6, duminică: 6, duminica: 6,
25
- });
26
- const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(послезавтрашн\p{L}*\s+дн\p{L}*|сегодняшн\p{L}*\s+дн\p{L}*|завтрашн\p{L}*\s+дн\p{L}*|післязавтрашн\p{L}*\s+дн\p{L}*|сьогоднішн\p{L}*\s+дн\p{L}*|сегодня|завтра|послезавтра|сьогодні|післязавтра|bugun|ertaga|indin|бүгін|ертең|бүрсігүні|бүгүн|эртең|бүрсүгүнү|astăzi|azi|mâine|maine|poimâine|poimaine|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
27
- const EXTENDED_RELATIVE_RE = /(?<![\p{L}\p{N}])(?:через\s+(\d+)\s+(дні|днів|тижд(?:ень|ні|нів|ня))|peste\s+(\d+)\s+(zile?|săptămân(?:ă|i)|saptaman(?:a|i))|(\d+)\s+(күннен|аптадан|кун(?:дөн|дон)|жумадан)\s+(?:кейін|кийин))(?![\p{L}\p{N}])/giu;
28
- const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
29
- const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
30
- const NEXT_WEEKDAY_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:со?\\s+следующ(?:его|ей)\\s+|з\\s+наступн(?:ого|ої)\\s+|next\\s+)(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
31
- const END_OF_MONTH_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:до|until|p[âa]nă\\s+la|gacha|дейін|чейин|available\\s+until)\\s+(?:конца|кінця|sf[âa]rșit(?:ul)?(?:\\s+lunii)?|end\\s+of)\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'giu');
32
- const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап)\s+(?:начала\s+месяца|початку\s+місяця|începutul\s+lunii|start\s+of\s+(?:the\s+)?month|1(?:-го)?\s+числа|первого\s+числа|1(?:-го)?\s+числа\s+місяця)(?![\p{L}\p{N}])/giu;
33
- const NEXT_MONTH_RE = /(?<![\p{L}\p{N}])(?:next\s+month|со?\s+следующего\s+месяца|з\s+наступного\s+місяця)(?![\p{L}\p{N}])/giu;
34
- const SHIFT_RE = /(?<!\d)(\d{1,2})\s*(?:смен[аы]|shift)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:-|–|—|до|to)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/giu;
35
-
36
- function referenceEntry(context = {}) {
37
- for (const [source, value] of [['referenceDate', context.referenceDate], ['publishedAt', context.publishedAt], ['fetchedAt', context.fetchedAt]]) {
38
- if (value == null || value === '') continue;
39
- const date = new Date(value);
40
- if (Number.isFinite(date.getTime())) return Object.freeze({ date, source });
41
- }
42
- return Object.freeze({ date: new Date(), source: 'currentDate' });
43
- }
44
- function referenceDate(context = {}) { return referenceEntry(context).date; }
45
- function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
46
- function validDate(value) { const date = new Date(Date.UTC(value.year, value.month - 1, value.day)); return date.getUTCFullYear() === value.year && date.getUTCMonth() === value.month - 1 && date.getUTCDate() === value.day; }
47
- function candidate(entityType, value, match, parser, confidence, evidence) { const start = match.index ?? 0; return createParseCandidate({ id: `${entityType}:${start}`, entityType, value, raw: match[0], start, end: start + match[0].length, parser, confidence, evidence }); }
48
- // Bare "с"/"з"/"до" are single Cyrillic letters and match as a substring of
49
- // countless ordinary words ("Сдаю", "доступна") without a boundary guard —
50
- // "Сдаю до 15.03" was misread as 'from' off the "С" in "Сдаю" alone, hiding
51
- // the actual "до" (until) marker that followed.
52
- const RELATION_FROM_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))(?![\p{L}\p{N}])/iu;
53
- const RELATION_UNTIL_RE = /(?<![\p{L}\p{N}])(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)(?![\p{L}\p{N}])/iu;
54
- function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return RELATION_FROM_RE.test(before) ? 'from' : RELATION_UNTIL_RE.test(before) ? 'until' : 'exact'; }
55
- // A year-less date is assumed to refer to its next occurrence when the
56
- // current year's reading has already passed — not just for explicit
57
- // "from"/"until" wording. A bare mention with no relation ('exact') is the
58
- // common case (e.g. "12 января встреча") and would otherwise silently
59
- // resolve to a date up to a year in the past.
60
- function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
61
- function parseClock(raw) { const match = String(raw).trim().match(new RegExp(String.raw`^(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?$`, 'iu')); if (!match) return null; let hour = Number(match[1]); const minute = Number(match[2] || 0); const suffix = String(match[3] || '').toLowerCase(); if (suffix === 'pm' && hour < 12) hour += 12; if (suffix === 'am' && hour === 12) hour = 0; if (/(?:вечера|вечора|kechqurun)/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
62
- function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year|yil|жыл|рок|\ban/i.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month|\boy|місяц|lun/i.test(normalized) ? 'month' : /нед|week|hafta|апта|тиж|săptăm|saptaman/i.test(normalized) ? 'week' : /дн|день|day|\bkun|күн|zi/i.test(normalized) ? 'day' : /мин|minute|daqiqa/i.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш|at\s+least/iu.test(prefix || '') ? 'min' : /до|не\s+более|ko'?pi\s+bilan|көп\s+емес/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
63
- function semanticDurationType(text, start, duration) {
64
- const before = text.slice(Math.max(0, start - 56), start);
65
- const after = text.slice(start, Math.min(text.length, start + 56));
66
- if (/(?:испытательн(?:ый)?\s+срок|probation)[^.;\n]{0,30}$/iu.test(before)) return 'probationDuration';
67
- if (/(?:контракт|contract)[^.;\n]{0,30}$/iu.test(before)) return 'contractDuration';
68
- if (/(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)/iu.test(after)) {
69
- if (duration?.bound === 'max') return 'maximumRentalDuration';
70
- if (duration?.bound === 'exact') return 'fixedRentalDuration';
71
- return 'minimumRentalDuration';
72
- }
73
- return 'duration';
74
- }
75
- function addUtcDays(date, days) { const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); return dateValue(copy.getUTCFullYear(), copy.getUTCMonth() + 1, copy.getUTCDate()); }
76
- function relativeDays(raw) { const lower = raw.toLowerCase(); if (/сегодня|сьогодні|bugun|бүгін|бүгүн|astăzi|azi|today/u.test(lower)) return 0; if (/послезавтра|післязавтра|indin|бүрсігүні|бүрсүгүнү|poimâine|poimaine|day\s+after/u.test(lower)) return 2; if (/завтра|ertaga|ертең|эртең|mâine|maine|tomorrow/u.test(lower)) return 1; if (/две\s+недели/u.test(lower)) return 14; if (/неделю/u.test(lower)) return 7; const numeric = Number(lower.match(/\d+/u)?.[0]); return Number.isFinite(numeric) ? numeric : null; }
77
- function relativeDurationDays(amount, unit) {
78
- const value = Number(amount);
79
- if (!Number.isFinite(value) || value < 0) return null;
80
- return /(?:тиж|săptăm|saptaman|апта|жума)/iu.test(unit) ? value * 7 : value;
81
- }
82
- function temporalContextType(text, start, context) { const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48)); if (/(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|бос|бош|вільн|disponibil|move[- ]?in|available)/iu.test(around)) return 'availabilityDate'; if ((context.domain === 'vacancy' || /(?:ваканс|работ|job|ish\s+grafigi|графік)/iu.test(around)) && /(?:выход|start|приступ|boshlash)/iu.test(around)) return 'startDate'; return 'relativeDate'; }
83
- function dateEntityType(text, start, context, relation = relationNear(text, start)) {
84
- const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48));
85
- if (/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)/iu.test(around)) return 'deadline';
86
- if (/(?:published|publication|опубликован|опублікован)/iu.test(around)) return 'publicationDate';
87
- // Direction must belong to this date's local left context. Looking ahead
88
- // across a whole listing makes "available from 12 February, until March"
89
- // incorrectly classify the February date as an end date.
90
- if (relation === 'until') return 'availabilityUntil';
91
- if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|вільн|disponibil|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
92
- return 'calendarDate';
93
- }
94
- function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: referenceEntry(context).source }] : []; }
95
- const SCHEDULE_CONTEXT_RE = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job|графік|змін[аи]|program(?:ul)?\s+de\s+lucru|ish\s+grafigi|жұмыс\s+кестесі|жумуш\s+графиги)/iu;
96
- function scheduleDaysOffMode(text) {
97
- if (/(?:плавающ|floating|flexible\s+days\s+off)/iu.test(text)) return 'floating';
98
- if (/(?:скользящ|сменн|rotating\s+days\s+off|выходные\s+по\s+графику)/iu.test(text)) return 'rotating';
99
- return 'fixed';
100
- }
101
- function cycleScheduleValue(work, rest, daysOffMode) {
102
- // Day cycles such as 2/2 and 5/2 are common. Values larger than a week in
103
- // an explicit schedule context are the conventional shift notation 24/48
104
- // or 12/24, not a claim of dozens of working days.
105
- if (Math.max(work, rest) > 7) {
106
- // An hour cycle has no fixed weekday rest days. Keep an explicitly
107
- // floating mode, otherwise expose the inherent repeating rotation.
108
- return Object.freeze({ type: 'cycle', cycleHours: Object.freeze({ work, rest }), daysOffMode: daysOffMode === 'fixed' ? 'rotating' : daysOffMode });
109
- }
110
- return Object.freeze({ type: 'cycle', workDays: work, restDays: rest, daysOffMode });
111
- }
112
- function isTimeRangeContextual(match, text) {
113
- if (/[.:]|\b(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)\b/iu.test(match[0])) return true;
114
- if (/^\s*(?:с|from)\b/iu.test(match[0])) return true;
115
- const start = match.index ?? 0;
116
- return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
117
- }
118
- function isClockContextual(match, text) {
119
- if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
120
- const start = match.index ?? 0;
121
- return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
122
- }
123
- function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
124
- function dateAtNextMonthStart(reference) { const month = reference.getUTCMonth() + 2; const year = reference.getUTCFullYear() + Math.floor((month - 1) / 12); return dateValue(year, ((month - 1) % 12) + 1, 1); }
125
- function nextWeekday(date, weekday) { const current = (date.getUTCDay() + 6) % 7; let days = (weekday - current + 7) % 7; if (days === 0) days = 7; return addUtcDays(date, days); }
8
+ /**
9
+ * Parser Engine V2's temporal subsystem. Each semantic category — calendar
10
+ * dates, relative dates, durations, clock/time-ranges, work schedules — has
11
+ * its own parser module (temporal-calendar-date.js, temporal-relative-
12
+ * date.js, temporal-duration.js, temporal-clock.js, temporal-schedule.js);
13
+ * this file only orchestrates them, resolves cross-category conflicts, and
14
+ * exposes the public parseTemporal()/extractTemporalCandidates() API.
15
+ * Nothing here returns a bare Date — every result is a typed entity
16
+ * (calendarDate/duration/workSchedule/timeRange/clockTime/...) with its own
17
+ * evidence, confidence and span.
18
+ */
126
19
 
127
20
  /** Extract generic temporal candidates without silently fabricating a Date. */
128
21
  export function extractTemporalCandidates(value, context = {}) {
129
- const text = String(value || ''); const candidates = [];
130
- for (const match of text.matchAll(new RegExp(DATE_NUMERIC_RE, 'gu'))) {
131
- const date = match[1] ? dateValue(Number(match[1]), Number(match[2]), Number(match[3])) : dateValue(Number(match[6]), Number(match[5]), Number(match[4]));
132
- if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context), date, match, 'temporal.calendar.numeric', .98, [{ type: 'regex', rule: 'numeric-date' }]));
133
- }
134
- for (const match of text.matchAll(new RegExp(DATE_NUMERIC_PARTIAL_RE, 'gu'))) {
135
- const start = match.index ?? 0; const around = text.slice(Math.max(0, start - 40), start + match[0].length + 40);
136
- if (!/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?|свобод|доступ|заезд|заезж|available|move[- ]?in|(?<![\p{L}\p{N}])(?:с|до|from|until)(?![\p{L}\p{N}]))/iu.test(around)) continue;
137
- const relation = relationNear(text, start); const inferred = true;
138
- const date = dateValue(inferYear(Number(match[2]), Number(match[1]), relation, context), Number(match[2]), Number(match[1]));
139
- if (validDate(date)) candidates.push(candidate(dateEntityType(text, start, context, relation), date, match, 'temporal.calendar.numeric-partial', .86, [{ type: 'regex', rule: 'numeric-partial-date' }, ...inferredDateEvidence(inferred, context)]));
140
- }
141
- for (const match of text.matchAll(new RegExp(DATE_WORD_RE, 'giu'))) {
142
- const monthIndex = HIRING_MONTHS[match[2].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
143
- const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[1]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[1]));
144
- if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-name', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[2] }, ...inferredDateEvidence(inferred, context)]));
145
- }
146
- for (const match of text.matchAll(new RegExp(DATE_MONTH_FIRST_RE, 'giu'))) {
147
- const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
148
- const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[2]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[2]));
149
- if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-first', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, ...inferredDateEvidence(inferred, context)]));
150
- }
151
- for (const match of text.matchAll(END_OF_MONTH_RE)) {
152
- const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
153
- const inferred = !match[2]; const year = inferred ? inferYear(monthIndex + 1, 1, 'until', context) : Number(match[2]);
154
- candidates.push(candidate('availabilityUntil', dateAtMonthEnd(year, monthIndex + 1), match, 'temporal.calendar.month-end', inferred ? .86 : .94, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, { type: 'relation', value: 'until-month-end' }, ...inferredDateEvidence(inferred, context)]));
155
- }
156
- for (const match of text.matchAll(START_OF_MONTH_RE)) {
157
- const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
158
- candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.month-start', .88, [{ type: 'context', value: 'month-start' }, { type: 'reference-date', value: referenceEntry(context).source }]));
159
- }
160
- for (const match of text.matchAll(NEXT_MONTH_RE)) {
161
- const date = dateAtNextMonthStart(referenceDate(context));
162
- candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.next-month', .88, [{ type: 'context', value: 'next-month' }, { type: 'reference-date', value: referenceEntry(context).source }]));
163
- }
164
- for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
165
- const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
166
- candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), nextWeekday(referenceDate(context), weekday), match, 'temporal.relative.next-weekday', .9, [{ type: 'dictionary', dictionary: 'weekdays', key: match[1] }, { type: 'reference-date', value: referenceEntry(context).source }]));
167
- }
168
- for (const match of text.matchAll(RELATIVE_RE)) { const days = relativeDays(match[1]); if (days == null) continue; const type = temporalContextType(text, match.index ?? 0, context); candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'reference-date', value: referenceEntry(context).source }])); }
169
- for (const match of text.matchAll(EXTENDED_RELATIVE_RE)) {
170
- const amount = match[1] || match[3] || match[5];
171
- const unit = match[2] || match[4] || match[6];
172
- const days = relativeDurationDays(amount, unit);
173
- if (days == null) continue;
174
- const type = temporalContextType(text, match.index ?? 0, context);
175
- candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date.extended', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'unit', value: unit }, { type: 'reference-date', value: referenceEntry(context).source }]));
176
- }
177
- for (const match of text.matchAll(new RegExp(DURATION_NUMBER_FIRST_RE, 'giu'))) { const value = durationValue(match[1], match[2], match[3]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
178
- for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
179
- if (match[3]) continue;
180
- // Bare genitive "дня" is almost always part of another phrase ("конца
181
- // дня", "сегодняшнего дня", "через 2 дня") rather than a standalone
182
- // 1-day duration. Only accept it here when an explicit duration prefix
183
- // ("на", "минимум", ...) makes the duration reading unambiguous.
184
- if (!match[1] && /^дня$/iu.test(match[2])) continue;
185
- const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])]));
186
- }
187
- const timeRangeSpans = [];
188
- for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
189
- const start = parseClock(match[1]); const end = parseClock(match[2]);
190
- if (!start || !end || !isTimeRangeContextual(match, text)) continue;
191
- timeRangeSpans.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
192
- candidates.push(candidate('timeRange', Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }), match, 'temporal.time-range', .98, [{ type: 'range', value: 'clock-time' }]));
193
- }
194
- for (const match of text.matchAll(new RegExp(TIME_RE, 'giu'))) {
195
- const start = match.index ?? 0; const end = start + match[0].length;
196
- if (timeRangeSpans.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd) || !isClockContextual(match, text)) continue;
197
- const clock = parseClock(match[0]); if (clock) candidates.push(candidate('clockTime', clock, match, 'temporal.clock-time', .94, [{ type: 'clock', value: 'explicit' }]));
198
- }
199
- const scheduleContext = SCHEDULE_CONTEXT_RE;
200
- for (const match of text.matchAll(/(?<![\d:.])(\d{1,2})\s*(?:\/|\\|через|-)\s*(\d{1,2})(?![\d:.])/giu)) {
201
- const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
202
- const daysOffMode = scheduleDaysOffMode(around);
203
- const work = Number(match[1]); const rest = Number(match[2]);
204
- candidates.push(candidate('workSchedule', cycleScheduleValue(work, rest, daysOffMode), match, 'temporal.schedule-cycle', .99, [{ type: 'context', value: 'schedule' }, { type: 'range', value: Math.max(work, rest) > 7 ? 'cycle-hours' : 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
205
- }
206
- for (const match of text.matchAll(/(?<![\p{L}\p{N}])два\s+через\s+два(?![\p{L}\p{N}])/giu)) {
207
- const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
208
- const daysOffMode = scheduleDaysOffMode(around);
209
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: 2, restDays: 2, daysOffMode }), match, 'temporal.schedule-cycle.words', .97, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
210
- }
211
- for (const match of text.matchAll(WEEKDAY_RANGE_RE)) {
212
- const start = DAY_ALIASES[match[1].toLowerCase()]; const end = DAY_ALIASES[match[2].toLowerCase()];
213
- if (start == null || end == null || end < start) continue;
214
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(start, end + 1), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays', .97, [{ type: 'range', value: 'weekday' }]));
215
- }
216
- for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:по\s+будням|weekdays?)(?![\p{L}\p{N}])/giu)) {
217
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(0, 5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays.named', .96, [{ type: 'context', value: 'weekdays' }]));
218
- }
219
- for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:только\s+по\s+выходным|weekends?\s+only)(?![\p{L}\p{N}])/giu)) {
220
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekends', .96, [{ type: 'context', value: 'weekends' }]));
221
- }
222
- const shifts = [];
223
- let firstShift = null; let lastShift = null;
224
- for (const match of text.matchAll(SHIFT_RE)) { const start = parseClock(match[2]); const end = parseClock(match[3]); if (!start || !end) continue; firstShift ||= match; lastShift = match; shifts.push(Object.freeze({ name: `${match[1]} shift`, hours: Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }) })); }
225
- if (shifts.length) candidates.push(candidate('shifts', Object.freeze(shifts), { 0: text.slice(firstShift.index, (lastShift.index ?? 0) + lastShift[0].length), index: firstShift.index }, 'temporal.multiple-shifts', .98, [{ type: 'context', value: 'shift' }, { type: 'range', value: 'clock-time' }]));
226
- return Object.freeze(candidates);
22
+ const text = String(value || '');
23
+ return Object.freeze([
24
+ ...extractCalendarDateCandidates(text, context),
25
+ ...extractRelativeDateCandidates(text, context),
26
+ ...extractDurationCandidates(text),
27
+ ...extractClockCandidates(text),
28
+ ...extractScheduleCandidates(text, parseClock),
29
+ ]);
227
30
  }
228
31
 
229
32
  // The default resolver only rejects overlapping candidates of the *same*