@whiteslove/parsing-lexicon 0.7.8 → 0.8.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.7.8",
3
+ "version": "0.8.1",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contact.js CHANGED
@@ -9,6 +9,7 @@ const PHONE_LIKE_RE = /\+?\d(?:[\t \u00a0().-]*\d){9,}/g;
9
9
  const PHONE_CANDIDATE_RE = /\+?\d(?:[\t \u00a0().-]*\d){6,}(?:[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*\d{1,6})?/giu;
10
10
  const PHONE_EXTENSION_RE = /[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*(\d{1,6})$/iu;
11
11
  const DATE_LIKE_PHONE_RE = /^\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})(?:\s+\d{1,2})?$/u;
12
+ const PRICE_LABEL_BEFORE_NUMBER_RE = /(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\s+плата)?|rent)\s*[:=\-–—]?\s*$/iu;
12
13
 
13
14
  const TELEGRAM_USERNAME_RE = /^[A-Za-z0-9_]{5,32}$/;
14
15
  const TELEGRAM_LINK_RE = /(?:https?:\/\/)?(?:t\.me|telegram\.me|telegram\.dog)\/([A-Za-z0-9_]{5,32})(?:\/[0-9]+)?(?:[/?#][^\s]*)?/giu;
@@ -20,6 +21,18 @@ function normalizedCountryHint(value) {
20
21
  return /^[A-Z]{2}$/.test(country) ? country : undefined;
21
22
  }
22
23
 
24
+ // PHONE_LIKE_RE deliberately accepts punctuation-separated digit sequences.
25
+ // That also resembles a grouped monetary range such as
26
+ // "Narxi: 950 000 - 1.000.000". A nearby explicit price label is stronger
27
+ // semantic evidence than the broad (unvalidated) ten-digit phone mask, so do
28
+ // not hide that span before the money candidate parser sees it. This does
29
+ // not weaken validated national-phone parsing below.
30
+ function isExplicitPriceSpan(text, start, raw) {
31
+ if (!/[\-–—]/u.test(raw)) return false;
32
+ const before = text.slice(Math.max(0, start - 48), start);
33
+ return PRICE_LABEL_BEFORE_NUMBER_RE.test(before);
34
+ }
35
+
23
36
  function splitPhoneExtension(raw) {
24
37
  const match = String(raw || '').match(PHONE_EXTENSION_RE);
25
38
  if (!match) return { base: String(raw || '').trim(), extension: null };
@@ -38,6 +51,7 @@ export function findPhoneLikeSpans(value, options = {}) {
38
51
  const digits = raw.replace(/\D/g, '');
39
52
  if (digits.length < 10) continue;
40
53
  const start = match.index ?? 0;
54
+ if (isExplicitPriceSpan(text, start, raw)) continue;
41
55
  spans.push(Object.freeze({
42
56
  start,
43
57
  end: start + raw.length,
@@ -1,8 +1,6 @@
1
1
  import { COUNTRIES, canonicalCountryCode } from './countries.js';
2
2
  import { CITIES } from './geography.js';
3
- import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
4
-
5
- const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ import { aliasesOf, aliasesToRegex, escapeRegex, normalizeForMatch } from './normalization.js';
6
4
 
7
5
  function cityInflectionRegex(aliases) {
8
6
  const cyrillic = [...new Set(aliases.filter((alias) => /\p{Script=Cyrillic}/u.test(alias)))];
@@ -1,6 +1,6 @@
1
1
  import type { HiringSeniority, DegreeLevel, DegreeField } from './hiring-requirements.js';
2
2
  export type HiringAtsProfile = Readonly<{ raw: string; skills: Set<string>; skillEvidence: Map<string, number>; terms?: Set<string>; experienceYears?: number; seniority?: HiringSeniority; degreeLevel?: DegreeLevel; degreeFields: Set<DegreeField>; requiresUsSponsorship?: boolean }>;
3
- export type HiringAtsJob = Readonly<{ title: string; description?: string; tags?: string[]; skills?: string[]; niceToHave?: string[]; experienceMinYears?: number; seniority?: string | null; education?: string; country?: string; location?: string; sponsorshipEvidence?: string[] }>;
3
+ export type HiringAtsJob = Readonly<{ title: string; description?: string; tags?: string[]; skills?: string[]; niceToHave?: string[]; experienceMinYears?: number; seniority?: string | null; education?: string; country?: string; location?: string }>;
4
4
  export function buildHiringAtsProfile(cvText: unknown, options?: { fuzzySkills?: boolean; referenceDate?: Date }): HiringAtsProfile;
5
5
  export function scoreHiringAts(profileOrCv: HiringAtsProfile | string, job: HiringAtsJob, options?: { fuzzySkills?: boolean; referenceDate?: Date }): Readonly<{ score: number; fitScore: number; eligible: boolean; blockers: readonly { code: string; label: string; critical: boolean }[]; breakdown: Readonly<{ skills: number; experience: number; seniority: number; scope: number; education: number; relevance: number }>; matched: readonly string[]; missing: readonly string[] }>;
6
6
  export function hiringAtsScoreColor(score: number): string;
package/src/hiring-ats.js CHANGED
@@ -137,10 +137,7 @@ export function scoreHiringAts(profileOrCv, job, options = {}) {
137
137
  const relevance = extractTerms(keywordSource).size ? Math.round(coverage(profile.terms || extractTerms(profile.raw), extractTerms(keywordSource)) * 100) : 60;
138
138
  const breakdown = Object.freeze({ skills, experience: experience.score, seniority: seniority.score, scope: scope.score, education: education.score, relevance });
139
139
  let fitScore = Math.round(skills * .30 + experience.score * .20 + seniority.score * .20 + scope.score * .15 + education.score * .10 + relevance * .05);
140
- // Source-specific ATS ingestion can supply the wording that established a
141
- // sponsorship policy separately from the rendered description. It remains
142
- // input evidence, not consumer-side parsing logic.
143
- const usText = `${job?.location || ''} ${title} ${description} ${tagText} ${(job?.sponsorshipEvidence || []).join(' ')}`;
140
+ const usText = `${job?.location || ''} ${title} ${description}`;
144
141
  const visaBlocked = (job?.country || '').toUpperCase() === 'US' || detectCountryCodeFromText(usText) === 'US'
145
142
  ? profile.requiresUsSponsorship === true && isNoSponsorshipRequirement(usText) : false;
146
143
  const blockers = visaBlocked ? [Object.freeze({ code: 'visa_sponsorship', label: 'Visa sponsorship unavailable', critical: true })] : [];
@@ -1,4 +1,9 @@
1
1
  import { cleanHiringSourceText } from './hiring-source-semantics.js'
2
+ import { escapeRegex } from './normalization.js'
3
+
4
+ // Kept as a compatibility export for existing hiring consumers. The lexical
5
+ // implementation itself has one canonical home in normalization.js.
6
+ export { escapeRegex } from './normalization.js'
2
7
 
3
8
  // These canonical labels are ordinary words or one-letter tokens. Matching the
4
9
  // label itself would create noisy results; only their explicit aliases are safe.
@@ -249,10 +254,6 @@ export function normalizeSkillText(value) {
249
254
  .trim()
250
255
  }
251
256
 
252
- export function escapeRegex(value) {
253
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
254
- }
255
-
256
257
  export function buildSkillRegex(alias) {
257
258
  const normalized = normalizeSkillText(alias)
258
259
  const pattern = escapeRegex(normalized).replace(/\s+/g, '\\s+')
@@ -8,8 +8,8 @@ import {
8
8
  const PHONE_RUN_RE = /\+?\d[\d\s().-]{7,}\d/gu;
9
9
  const ADDRESS_LABEL_RE = /(?:адрес|адреса|адресація|адресация|manzil|address|adresă|adresa)\s*[:=\-–—]\s*/iu;
10
10
  const PREFIX_STREET_MARKER = String.raw`(?:(?:ул(?:ица)?|вул(?:иця)?|пр|просп(?:ект)?|пр-т|переул(?:ок)?|пров(?:улок)?|проезд|наб(?:ережная)?|шоссе|str(?:ada)?|street|st|avenue|ave|road|rd|көше)\.?)`;
11
- const POSTFIX_STREET_MARKER = String.raw`(?:ko['’ʼ\u02bc]?cha(?:si)?|кўча(?:си)?|коча(?:си)?|kocha(?:si)?)`;
12
- const POSTFIX_STREET_TYPE = String.raw`(?:вулиця|улица|провулок|переулок|проспект|бульвар|набережна|набережная|шосе|шоссе|площа|площадь|узвіз|спуск|алея|аллея|дорога|тупик)`;
11
+ const POSTFIX_STREET_MARKER = String.raw`(?:ko['’ʼ\u02bc]?cha(?:si)?|кўча(?:си)?|коча(?:си)?|kocha(?:si)?|көше(?:сі)?|көшесі|көчө(?:сү)?)`;
12
+ const POSTFIX_STREET_TYPE = String.raw`(?:вулиця|улица|провулок|переулок|проспект|бульвар|набережна|набережная|шосе|шоссе|площа|площадь|узвіз|спуск|алея|аллея|дорога|тупик|көше(?:сі)?|көшесі|көчө(?:сү)?)`;
13
13
  const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.|uy|уй|үй|nr\.?|no\.?|№)`;
14
14
  const BUILDING_MARKER = String.raw`(?:корп(?:ус)?\.?|к\.|строен(?:ие)?|стр\.|будова|секц(?:ия|ія)?|bloc|corp|building|bldg\.?|korpus|bino|bina|бино)`;
15
15
  const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}]\d{0,4})?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}]\d{0,4})?){0,2}`;
@@ -17,9 +17,9 @@ const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
17
17
  const SECONDARY_TOKEN = String.raw`(?:${NUMBER_TOKEN}|[\p{L}])`;
18
18
  const LEVEL_NUMBER_TOKEN = String.raw`\d{1,3}(?:[-–—]?(?:й|ый|ий|st|nd|rd|th))?`;
19
19
  const LEVEL_MARKER = String.raw`(?:этаж(?:е|у|ом)?|поверх(?:у|е|ом)?|floor|qavat(?:da)?|қабат(?:та)?|кават|қават|etaj(?:da|ul)?)`;
20
- const ENTRANCE_MARKER = String.raw`(?:подъезд|під['’ʼ\u02bc]?їзд|entrance|intrare|kirish)`;
20
+ const ENTRANCE_MARKER = String.raw`(?:подъезд|під['’ʼ\u02bc]?їзд|entrance|intrare|kirish|кіреберіс|кире\s+бериш)`;
21
21
  const STAIRCASE_MARKER = String.raw`(?:лестниц(?:а|ы)?|сходи|staircase|scara)`;
22
- const ADDRESS_FIELD_STOP_RE = /\s+(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|этаж(?:ность)?|поверх|qavat|қабат|кават|қават|комнат(?:ы|а)?|кімнат(?:и|а)?|xona|хона|площадь|площа|maydon|тел(?:ефон)?|phone|комисси\p{L}*|депозит|deposit|ориентир\p{L}*|ор[-–—]?р\.?)(?=$|[\s:№#-])/iu;
22
+ const ADDRESS_FIELD_STOP_RE = /\s+(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|этаж(?:ность)?|поверх|qavat|қабат|кават|қават|комнат(?:ы|а)?|кімнат(?:и|а)?|xona|хона|площадь|площа|maydon|тел(?:ефон)?|phone|комисси\p{L}*|депозит|deposit|ориентир\p{L}*|ор[-–—]?р\.?|власник|собственник|owner|риелтор\p{L}*|агент\p{L}*|подробност\p{L}*|звоните|пиш(?:ите|іть))(?=$|[\s:№#-])/iu;
23
23
  const PROPERTY_AREA_LINE_RE = /(?:^|[^\p{L}\p{N}_])(?:(?:общая|жилая|полезная|кухонная)\s+площадь|площадь\s+(?:квартиры|дома|комнаты))(?=$|[^\p{L}\p{N}_])/iu;
24
24
  const NON_ADDRESS_BARE_RE = /^(?:(?:(?:перш(?:ий|ому)|перв(?:ый|ом)|друг(?:ий|ому)|втор(?:ой|ом)|трет(?:ій|ьем|ий)|\d{1,3}(?:-?й)?)\s+(?:поверх|этаж|floor|qavat|қабат))|(?:поверх|этаж|floor|qavat|қабат)(?:\s|$)|(?:район|р-н|рн|мікрорайон|микрорайон|мкр\.?|жк|ж\.к\.|жилой\s+комплекс|житловий\s+комплекс|residential\s+complex)(?:\s|$)|(?:недалеко|поруч|рядом|біля|около|возле)(?=$|[^\p{L}\p{N}_])|(?:зупинка|остановка|станція|станция)(?:\s|$))/iu;
25
25
  const DELIMITED_STREET_REJECT_RE = /(?:^|\s)(?:город|місто|city|район|р-н|рн|мікрорайон|микрорайон|мкр|жк|метро|поверх|этаж|floor|qavat|кімнат\p{L}*|комнат\p{L}*|квартира|квартири|квартиры|оренда|аренда|продаж\p{L}*|цена|ціна|площад\p{L}*|площа|зупинка|остановка|ориентир\p{L}*|ор[-–—]?р\.?)(?:\s|$)/iu;
@@ -189,9 +189,9 @@ function result(address, street = null, houseNumber = null, building = null, con
189
189
  const normalizedHouseNumber = normalizeNumber(houseNumber);
190
190
  const compactBuilding = normalizedHouseNumber?.match(/^(\d{1,5})(?:к|k)(\d{1,4})$/iu);
191
191
  const normalizedBuilding = normalizeNumber(building) || compactBuilding?.[2] || null;
192
- // The public address is always a verified street component, optionally
193
- // followed by its house/building. Never preserve a source label's whole
194
- // location sentence as an address just because it was parsed on that path.
192
+ // `address` is a geocoding-compatible street/house value, never a fallback
193
+ // copy of a whole labelled listing line. Districts, metros and nearby POIs
194
+ // are preserved as independent components by the caller.
195
195
  const canonicalAddress = normalizedStreet
196
196
  ? composeHousingAddress({ street: normalizedStreet, houseNumber: compactBuilding ? compactBuilding[1] : normalizedHouseNumber, building: normalizedBuilding })
197
197
  : null;
@@ -420,6 +420,10 @@ function explicitStreetAddress(text) {
420
420
  .map((part) => clean(part).slice(0, 1200))
421
421
  .filter(Boolean)
422
422
  .slice(0, 12);
423
+ const candidates = [];
424
+ const add = (value) => {
425
+ if (value?.street) candidates.push(value);
426
+ };
423
427
 
424
428
  for (const rawLine of lines) {
425
429
  if (PROPERTY_AREA_LINE_RE.test(rawLine)) continue;
@@ -427,30 +431,37 @@ function explicitStreetAddress(text) {
427
431
  if (!line) continue;
428
432
 
429
433
  const postfixTyped = postfixTypedStreetAddress(line);
430
- if (postfixTyped) return postfixTyped;
434
+ if (postfixTyped) {
435
+ add(postfixTyped);
436
+ continue;
437
+ }
431
438
 
432
439
  const prefixTyped = prefixTypedStreetAddress(line);
433
- if (prefixTyped) return prefixTyped;
440
+ if (prefixTyped) {
441
+ add(prefixTyped);
442
+ continue;
443
+ }
434
444
 
435
445
  const boundedPrefix = line.match(new RegExp(
436
446
  `(?:^|[\\s,;])${PREFIX_STREET_MARKER}(?!\\p{L})\\s*((?:${STREET_WORD}\\s+){0,3}${STREET_WORD})(?=$|[,;])`,
437
447
  'iu',
438
448
  ));
439
449
  if (boundedPrefix) {
440
- return result(
450
+ add(result(
441
451
  boundedPrefix[0],
442
452
  boundedPrefix[1],
443
453
  null,
444
454
  null,
445
455
  scoreAddressConfidence(line, { source: 'explicit', hasStreetMarker: true, hasHouse: false }),
446
- );
456
+ ));
457
+ continue;
447
458
  }
448
459
 
449
460
  const prefix = line.match(new RegExp(`(?:^|[\\s,;])(${PREFIX_STREET_MARKER})\\s+(.+)$`, 'iu'));
450
461
  if (prefix) {
451
462
  const tail = splitAddressTail(prefix[2]);
452
463
  if (tail) {
453
- return result(
464
+ add(result(
454
465
  line,
455
466
  tail.street,
456
467
  tail.houseNumber,
@@ -460,7 +471,8 @@ function explicitStreetAddress(text) {
460
471
  hasStreetMarker: true,
461
472
  hasHouse: Boolean(tail.houseNumber),
462
473
  }),
463
- );
474
+ ));
475
+ continue;
464
476
  }
465
477
  }
466
478
 
@@ -469,7 +481,7 @@ function explicitStreetAddress(text) {
469
481
  const tailText = clean(`${postfix[1]} ${postfix[3]}`);
470
482
  const tail = splitAddressTail(tailText);
471
483
  if (tail) {
472
- return result(
484
+ add(result(
473
485
  line,
474
486
  tail.street,
475
487
  tail.houseNumber,
@@ -479,12 +491,23 @@ function explicitStreetAddress(text) {
479
491
  hasStreetMarker: true,
480
492
  hasHouse: Boolean(tail.houseNumber),
481
493
  }),
482
- );
494
+ ));
483
495
  }
484
496
  }
485
497
  }
486
498
 
487
- return null;
499
+ // Listing text often names a nearby street before the actual postal
500
+ // address. Keep alternatives long enough to rank component evidence rather
501
+ // than returning whichever regex happened to run first.
502
+ return candidates
503
+ .map((value, index) => ({
504
+ value,
505
+ index,
506
+ score: (Number(value.confidence) || 0)
507
+ + (value.houseNumber ? 0.18 : 0)
508
+ + (value.building ? 0.03 : 0),
509
+ }))
510
+ .sort((a, b) => b.score - a.score || a.index - b.index)[0]?.value || null;
488
511
  }
489
512
 
490
513
  function knownStreetAddress(text, knownStreet) {
@@ -540,22 +563,13 @@ function labelledAddress(text, rawValue) {
540
563
 
541
564
  const line = clean(rawLine).slice(0, 140);
542
565
  if (!line) return null;
543
- const structured = parseHousingAddress(line, { allowDelimitedBare: true });
544
- if (structured.street || structured.houseNumber) return structured;
545
-
546
- // A label alone is not evidence that arbitrary prose is a street. Accept
547
- // an unmarked form only when it is a short street-plus-house expression;
548
- // landmark lists such as "Yakkasaroy, Magic City yonida" remain geo data.
549
- const tail = splitAddressTail(line);
550
- const candidateStreet = compactStreet(tail?.street);
551
- const safeBare = Boolean(
552
- tail?.houseNumber
553
- && candidateStreet
554
- && !LOCATION_RELATION_RE.test(candidateStreet)
555
- && candidateStreet.split(/\s+/u).length <= 5
556
- && candidateStreet.split(/\s+/u).every((word) => /^[\p{L}'’.-]{2,48}$/u.test(word)),
557
- );
558
- return safeBare ? parseHousingAddress(line, { allowBare: true }) : structured;
566
+ const delimited = parseHousingAddress(line, { allowDelimitedBare: true });
567
+ if (delimited.street) return delimited;
568
+ // A generic label such as "Manzil:" is often followed by district and POI
569
+ // prose. Accept an unmarked bare form only when it has an actual house
570
+ // component; marker-based street forms were already handled above.
571
+ const bare = parseHousingAddress(line, { allowBare: true });
572
+ return bare.street && bare.houseNumber && !LOCATION_RELATION_RE.test(bare.street) ? bare : null;
559
573
  }
560
574
 
561
575
  function plausibleDelimitedStreet(value) {
@@ -11,6 +11,7 @@ import { parseHousingAddress, resolveHousingAddressGeoEntities } from './housing
11
11
  import { HOUSING_LANDMARK_EXTENSIONS, HOUSING_POI_EXTENSIONS } from './housing-poi-extensions.js';
12
12
  import { resolveHousingIntent } from './housing-intent.js';
13
13
  import { extractHousingPoiRelations } from './housing-poi-relations.js';
14
+ import { dictionaryFor } from './locations-runtime.js';
14
15
 
15
16
  const GENERIC_CATEGORY = Object.freeze({
16
17
  Park: 'park', Metro: 'metro', 'Bus stop': 'transport', 'Public transport': 'transport', 'Main road': 'transport',
@@ -31,19 +32,26 @@ const APPLIANCE_PATTERNS = Object.freeze([
31
32
  const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
32
33
  const LANDLORD_PRESENT_RE = /(?:xozaykali|hojaykali|xo['’]?jaykali|с\s+хозяйк(?:ой|ой\s+в\s+квартире)|хозяйк\p{L}*\s+(?:жив[её]т|прожива\p{L}*)|with\s+(?:the\s+)?(?:landlord|owner)\s+(?:present|living\s+in)|cu\s+proprietar(?:ul)?\s+în\s+cas(?:ă|a)|үй\s*иесі\s+(?:тұрады|бірге\s+тұрады))/iu;
33
34
  const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s+студент|students?\s+(?:only|welcome)|for\s+students|pentru\s+studen[țt]i|studen[țt]i(?:lor)?|студенттерге|студенттер\s+үшін|(?:oila|oyla)(?:ga|lar|li)?\s+yoki\s+\d{1,2}\s+ta\s+bola(?:lar)?(?:ga)?\s+(?:ijara(?:ga)?\s+)?(?:beril|topshiril))/iu;
34
- const NO_BROKER_RE = /(?:bez\s+makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси)|no\s+(?:broker|agent|commission|agency\s+fee)|f[ăa]r[ăa]\s+(?:comision|agen[țt]ie|intermediari)|делдалсыз|комиссиясыз)/iu;
35
+ const NO_BROKER_RE = /(?:bez\s*makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси)|no\s+(?:broker|agent|commission|agency\s+fee)|f[ăa]r[ăa]\s+(?:comision|agen[țt]ie|intermediari)|делдалсыз|комиссиясыз)/iu;
35
36
  const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
36
37
  const MEN_RE = /(?:o['’ʻʼ‘`]?g['’ʻʼ‘`]?il\s+bola(?:lar)?(?:ga)?|ogil\s+bola(?:lar)?(?:ga)?|sherik\s+bola|эркак(?:лар)?|erkak(?:lar)?(?:ga)?|только\s+(?:мужчин|парн)|\bmen\s+only\b|b[ăa]rba[țt]i(?:lor)?|b[ăa]ie[țt]i(?:lor)?|жігіттерге|жігіттер(?:ге)?|хлопц(?:ям|і|ів)?|чоловік(?:ам|и)?)/iu;
37
38
  const WOMEN_RE = /(?:qiz(?:lar)?(?:ga)?|ayol(?:lar)?(?:ga|ni)?|киз(?:лар)?(?:га)?|аёл(?:лар)?(?:га|ни)?|девушк\p{L}*|женщин\p{L}*|girls?\s+only|women\s+only|fete(?:lor)?|femei(?:lor)?|қыздарға|қыздар(?:ға)?|дівчат(?:ам|а|ок)?|жінк(?:ам|и)?)/iu;
38
39
  const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oyla(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga|famil(?:ie|ia)|cuplu(?:ri)?|отбасына|отбасылы|жанұяға|сім['’ʼ]?[яїі](?:ям|ям[иі])?|сімейн\p{L}*)/iu;
40
+ // A category word in an exclusion clause is negative evidence, not a tenant
41
+ // preference. Keep this narrow and local to avoid rejecting ordinary
42
+ // "oila uchun" / "для семьи" invitations elsewhere in the listing.
43
+ const FAMILY_EXCLUSION_RE = /(?:oila|oyla|семь\p{L}*|family|famil(?:ie|ia)|cuplu(?:ri)?|отбас\p{L}*|жанұя\p{L}*)[^\r\n.!?]{0,48}(?:bezovta\s+qilmasin|murojaat\s+qilmasin|kerak\s+emas|qabul\s+qilinmaydi|не\s+(?:беспокоить|принимаем)|not\s+(?:allowed|welcome))/iu;
39
44
  const ROOM_SHARE_RE = /(?:sherik(?:ka|lik|likga)?|шерик(?:ка|лик)?|roommate|flatmate|подселени|койко[-\s]?мест|место\s+в\s+(?:комнат|квартир)|birga\s+yashash(?:ga)?|kvartira(?:ga|da)?[^\r\n.!?]{0,36}(?:\d+|bitta|1)\s*(?:ta\s*)?(?:qiz|ayol)[^\r\n.!?]{0,20}(?:ijarachi\s*)?(?:kerak|kere)|coleg\s+de\s+(?:apartament|camer[ăa])|bed\s+space|бөлмелес(?:\s+керек)?|көрші\s+керек)/iu;
40
45
  const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
41
46
  const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
42
47
  const WALK_MINUTES_RE = /(?:yayov|piyoda|пешком)\s*(\d{1,2})\s*(?:daqiqa|min(?:ute)?s?|минут)/iu;
43
48
  const TRANSIT_ROUTES_RE = /(?:aftobuslar|avtobuslar|автобуслар|автобусы)[^\r\n\d]{0,24}((?:\d{1,4}[\s,;/]*){1,10})/iu;
44
- const NEARBY_RELATION_TAIL_RE = /(?<!\p{L})(?:рядом\s+(?:с|со)|недалеко\s+от|возле|около|ориентир\s*[:—–-]?|ор[-–—]?р\.?\s*[:—–-]?|near(?:by)?|close\s+to|lângă|aproape\s+de)(?!\p{L})[^.!?\r\n;]*/giu;
49
+ const NEARBY_RELATION_TAIL_RE = /(?<!\p{L})(?:рядом\s+(?:с|со)|недалеко\s+от|возле|около|напротив|навпроти|ориентир\s*[:—–-]?|ор[-–—]?р\.?\s*[:—–-]?|near(?:by)?|close\s+to|next\s+to|opposite|behind|in\s+front\s+of|yaqin(?:ida)?|lângă|aproape\s+de|în\s+apropiere\s+de|vizavi\s+de|în\s+spatele|în\s+fața)(?!\p{L})[^.!?\r\n;]*/giu;
50
+ const NEARBY_POSTFIX_RELATION_RE = /(?<!\p{L})[^,;.!?\r\n]{2,96}?\s+(?:yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida|жанында|қасында|жакын|каршысында|қарсысында|артында|алдында)(?=$|[,;.!?\r\n])/giu;
45
51
  const NEARBY_TRAVEL_TAIL_RE = /(?<!\p{L})(?:до|până\s+la)(?!\p{L})[^.!?\r\n;]{0,96}(?<!\p{L})\d{1,3}\s*(?:мин(?:ут(?:ы|а|ах)?|\.?)?|min(?:ute)?s?|дақиқ\p{L}*|daqiqa|км|km|метр(?:а|ов)?|m)(?!\p{L})[^.!?\r\n;]*/giu;
46
52
  const RESIDENTIAL_CONTEXT_RE = /(?:ж\.?\s*к\.?|жил(?:ой|ого)\s+комплекс|новострой(?:ка|ки)?|residential\s+complex|residence|turar\s+joy|uy[-\s]?joy|majmua|массив)/iu;
53
+ const METRO_PREFIX_RE = /(?:^|[^\p{L}])(?:metro|metrosi|метро|м\.)\s*$/iu;
54
+ const SUPERMARKET_PREFIX_RE = /(?:^|[^\p{L}])(?:супермаркет|supermarket|магазин)\s*$/iu;
47
55
 
48
56
  function unique(values) {
49
57
  return [...new Set(values.filter(Boolean))];
@@ -55,7 +63,7 @@ function categoryOf(entry) {
55
63
 
56
64
  function nearbyReferenceRanges(text) {
57
65
  const ranges = [];
58
- for (const pattern of [NEARBY_RELATION_TAIL_RE, NEARBY_TRAVEL_TAIL_RE]) {
66
+ for (const pattern of [NEARBY_RELATION_TAIL_RE, NEARBY_POSTFIX_RELATION_RE, NEARBY_TRAVEL_TAIL_RE]) {
59
67
  const regex = new RegExp(pattern.source, pattern.flags);
60
68
  for (const match of text.matchAll(regex)) {
61
69
  const start = match.index ?? 0;
@@ -72,6 +80,7 @@ function insideNearbyReference(match, ranges) {
72
80
  function withoutNearbyLocationReferences(text) {
73
81
  return String(text || '')
74
82
  .replace(NEARBY_RELATION_TAIL_RE, ' ')
83
+ .replace(NEARBY_POSTFIX_RELATION_RE, ' ')
75
84
  .replace(NEARBY_TRAVEL_TAIL_RE, ' ');
76
85
  }
77
86
 
@@ -122,6 +131,42 @@ function genericMatches(text) {
122
131
  }));
123
132
  }
124
133
 
134
+ // A city dictionary is the canonical lexical source. Collect every contextual
135
+ // match here instead of using matchDictionaryLocation(), which intentionally
136
+ // returns just one longest match for simple lookup callers.
137
+ function contextualCityMatches(text, country, city, type, prefix) {
138
+ const dictionary = dictionaryFor(String(country || '').toUpperCase(), city);
139
+ if (!dictionary || !prefix) return [];
140
+ const matches = [];
141
+ for (const entry of dictionary[type] || []) {
142
+ if (!entry?.re) continue;
143
+ const flags = [...new Set(`${entry.re.flags.replace(/g/g, '')}g`)].join('');
144
+ const re = new RegExp(entry.re.source, flags);
145
+ for (const match of text.matchAll(re)) {
146
+ const start = match.index ?? 0;
147
+ const before = text.slice(Math.max(0, start - 40), start);
148
+ if (!prefix.test(before)) continue;
149
+ matches.push({ canonical: entry.name, start, length: match[0].length });
150
+ }
151
+ }
152
+ return matches.sort((a, b) => a.start - b.start || b.length - a.length || a.canonical.localeCompare(b.canonical));
153
+ }
154
+
155
+ function cityMetro(text, country, city) {
156
+ return contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE)[0]?.canonical || null;
157
+ }
158
+
159
+ function contextualNearby(text, country, city, metro) {
160
+ const shops = contextualCityMatches(text, country, city, 'landmarks', SUPERMARKET_PREFIX_RE)
161
+ .map((match) => match.canonical);
162
+ const generic = parseHousingNearby(text).filter((name) => {
163
+ if (name === 'Metro' && metro) return false;
164
+ if (name === 'Supermarket' && shops.length) return false;
165
+ return true;
166
+ });
167
+ return deepFreeze(unique([...shops, ...generic]));
168
+ }
169
+
125
170
  export function parseHousingNearby(value) {
126
171
  const text = normalizeUnicode(value ?? '');
127
172
  if (!text) return deepFreeze([]);
@@ -135,7 +180,7 @@ export function parseHousingNearby(value) {
135
180
 
136
181
  export function parseHousingAudience(value) {
137
182
  const text = normalizeUnicode(value ?? '');
138
- const family = FAMILY_RE.test(text);
183
+ const family = FAMILY_RE.test(text) && !FAMILY_EXCLUSION_RE.test(text);
139
184
  const women = WOMEN_RE.test(text);
140
185
  const men = MEN_RE.test(text);
141
186
  const students = STUDENT_RE.test(text);
@@ -246,7 +291,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
246
291
  const observedAmenities = parseHousingObservedAmenities(text);
247
292
  const quarter = matchTashkentHousingQuarter(text);
248
293
  const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
249
- const metro = matchTashkentHousingMetro(text)?.name || null;
294
+ const metro = matchTashkentHousingMetro(text)?.name || cityMetro(text, country, city) || null;
250
295
  const primaryResidentialText = withoutNearbyLocationReferences(text);
251
296
  const parsedRc = specificResidentialComplex(primaryResidentialText)
252
297
  || matchTashkentResidentialComplex(primaryResidentialText)?.name
@@ -314,7 +359,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
314
359
  perPersonPrice,
315
360
  transitRoutes: parseHousingTransitRoutes(text),
316
361
  walkMinutes: walkMinutes(text),
317
- nearby: parseHousingNearby(text),
362
+ nearby: contextualNearby(text, country, city, metro),
318
363
  ...(poiRelations.length ? { poiRelations, nearbyEntities: deepFreeze(nearbyEntities) } : {}),
319
364
  amenities: observedAmenities,
320
365
  district: district || null,
@@ -102,7 +102,9 @@ function parseUtilitiesAmount(text) {
102
102
  function parseCommunalSeparated(text, country) {
103
103
  if (/(коммунал\p{L}*(?:\s+услуг\p{L}*)?\s*(?:отдельно|сверху|плюс|оплачива\p{L}*\s*отдельно)|свет\s*вода\s*газ\s*отдельно|k[oa]munal\p{L}*\s*(?:alohida|aloxida|ustiga)|камунал\s+туловлари\s+алохида|коммунал\s+тўловлари\s+алоҳида|utilities?\s*(?:separate|extra|not included))/iu.test(text)) return true;
104
104
  if (/(коммунал\p{L}*(?:\s+услуг\p{L}*)?\s*(?:включ|входит|в\s*стоимост)|вс[её]\s*включ|all\s*inclusive|kommunal\p{L}*\s*(?:kiritilgan|ichida)|комунал(?:каси)?\s+ичида|коммунал(?:каси)?\s+ичида|utilities?\s*included)/iu.test(text)) return false;
105
- return String(country || '').toUpperCase() === 'UA' ? true : null;
105
+ // Utility-payment terms must be explicit. A country default turns ordinary
106
+ // Ukrainian listings into a made-up "utilities separate" assertion.
107
+ return null;
106
108
  }
107
109
 
108
110
  function parseDepositRequired(text) {
@@ -10,7 +10,7 @@ export type HousingMoneyParseContext = Readonly<{
10
10
  fallbackCurrency?: string;
11
11
  dealType?: 'sale' | 'longRent' | 'shortRent' | string | null;
12
12
  }>;
13
- export type HousingMoneyCandidate = Readonly<{ amount: number; currency: string; start: number; end: number; explicitCurrency: boolean; scale: string | null; priceKeyword: boolean; paymentRole: string; approximate: boolean; confidenceBoost: number; confidence: number }>;
13
+ export type HousingMoneyCandidate = Readonly<{ amount: number; currency: string; start: number; end: number; explicitCurrency: boolean; scale: string | null; priceKeyword: boolean; range: Readonly<{ minimum: number; maximum: number }> | null; paymentRole: string; approximate: boolean; confidenceBoost: number; confidence: number }>;
14
14
  export function extractHousingMoneyCandidates(value: unknown, context?: string | HousingMoneyParseContext): readonly HousingMoneyCandidate[];
15
15
  export function rankHousingPriceCandidates(candidates: readonly HousingMoneyCandidate[]): readonly HousingMoneyCandidate[];
16
16
 
@@ -1,4 +1,4 @@
1
- import { aliasesOf, findCanonical } from './normalization.js';
1
+ import { aliasesOf, escapeRegex, findCanonical } from './normalization.js';
2
2
  import { CURRENCY_TERMS, NUMBER_MULTIPLIERS } from './money-lexicon.js';
3
3
  import {
4
4
  MONEY_NUMBER_PATTERN,
@@ -31,10 +31,6 @@ const PAYMENT_AMOUNT_TERMS = Object.freeze([
31
31
  ].filter(Boolean));
32
32
  const PRICE_KEYWORD_RE = new RegExp(PRICE_KEYWORD, 'iu');
33
33
 
34
- function escapeRegex(value) {
35
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
- }
37
-
38
34
  // One-letter `m/м` is deliberately excluded from the generic multiplier set.
39
35
  // Its meaning is resolved by housing-numeric-spans.js first, so area/distance/
40
36
  // microdistrict notation cannot leak into money parsing while explicit price
@@ -203,7 +199,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
203
199
  const text = maskPhoneLikeSpans(String(value || ''), ' ', { country });
204
200
  const candidates = [];
205
201
  const seen = new Set();
206
- const addCandidate = ({ amount, currency, start, end, explicitCurrency, scale = null, priceKeyword = false, confidenceBoost = 0 }) => {
202
+ const addCandidate = ({ amount, currency, start, end, explicitCurrency, scale = null, priceKeyword = false, range = null, confidenceBoost = 0 }) => {
207
203
  if (amount == null || amount < 1 || amount > 5_000_000_000 || seen.has(`${start}:${end}`)) return;
208
204
  seen.add(`${start}:${end}`);
209
205
  candidates.push(Object.freeze({
@@ -214,6 +210,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
214
210
  explicitCurrency,
215
211
  scale,
216
212
  priceKeyword,
213
+ range,
217
214
  paymentRole: candidatePaymentRole(text, start, end),
218
215
  approximate: APPROXIMATE_RE.test(text.slice(Math.max(0, start - 12), end)),
219
216
  confidenceBoost,
@@ -243,6 +240,33 @@ export function extractHousingMoneyCandidates(value, context = '') {
243
240
  });
244
241
  }
245
242
 
243
+ // A price-labelled range is common in Telegram rentals even when the author
244
+ // omits "sum". In Uzbek country context, grouped endpoints are an explicit
245
+ // UZS signal; preserve both endpoints on the candidate while exposing the
246
+ // lower bound through the legacy single-price result. It must be collected
247
+ // before generic amounts so the second endpoint cannot be selected merely
248
+ // because it is larger.
249
+ const labelledPriceRangeRe = new RegExp(
250
+ `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
251
+ 'igu',
252
+ );
253
+ for (const match of text.matchAll(labelledPriceRangeRe)) {
254
+ const minimum = parseNumericAmount(match[1]);
255
+ const maximum = parseNumericAmount(match[2]);
256
+ if (minimum == null || maximum == null || minimum > maximum) continue;
257
+ const start = match.index ?? 0;
258
+ addCandidate({
259
+ amount: Math.round(minimum),
260
+ currency: country === 'UZ' ? 'UZS' : fallbackCurrency || '',
261
+ start,
262
+ end: start + match[0].length,
263
+ explicitCurrency: false,
264
+ priceKeyword: true,
265
+ range: Object.freeze({ minimum: Math.round(minimum), maximum: Math.round(maximum) }),
266
+ confidenceBoost: 0.2,
267
+ });
268
+ }
269
+
246
270
  const splitMillionRe = /(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/giu;
247
271
  for (const match of text.matchAll(splitMillionRe)) {
248
272
  const start = match.index ?? 0;
@@ -1,5 +1,6 @@
1
1
  import { deepFreeze } from './lexicon-core.js';
2
2
  import { normalizeUnicode } from './normalization.js';
3
+ import { canonicalCity } from './geography.js';
3
4
 
4
5
  const TYPE_MARKERS = Object.freeze([
5
6
  ['poi.university', /(?:university|uni\b|institute|academy|college|universitet|institut|universiteti|университет|универ|институт|академия)/iu],
@@ -12,30 +13,30 @@ const TYPE_MARKERS = Object.freeze([
12
13
  ['poi.bus_station', /(?:bus\s+station|coach\s+station|автовокзал|автостанция|bus\s+terminal)/iu],
13
14
  ['poi.parking', /(?:parking|парковк\p{L}*|паркинг|автостоянк\p{L}*)/iu],
14
15
  ['poi.shopping_mall', /(?:shopping\s+(?:mall|cent(?:er|re))|mall\b|т[цр]\b|savdo\s+markaz)/iu],
16
+ ['poi.supermarket', /(?:supermarket|супермаркет|гипермаркет|магазин)/iu],
15
17
  ['poi.market', /(?:market|bazaar|bozor|базар|рынок)/iu],
16
18
  ['poi.park', /(?:park|bog['’ʻʼ`]?|парк)/iu],
17
19
  ['metro', /(?:metro|metrosi|метро|м\.)/iu],
18
20
  ]);
19
21
 
20
- const RELATION_RE = /(?<relation>рядом\s+(?:с|со)|возле|около|недалеко\s+от|напротив|за|перед|near(?:by)?|close\s+to|next\s+to|opposite|behind|in\s+front\s+of|yaqin(?:ida)?|yonida|ro['’ʻʼ`]?parasida|орналасқан\s+жерде|поруч|біля|поблизу|lângă|aproape\s+de)\s+(?<target>[^,;.!?\r\n]{2,96})/giu;
21
- // Uzbek usually places the relation after the landmark: "Magic City yonida"
22
- // rather than "yonida Magic City". Keep its target span separate so it never
23
- // leaks into an address/street field.
24
- const POSTFIX_RELATION_RE = /(?<target>[^,;.!?\r\n]{2,96}?)\s+(?<relation>yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida)(?=$|[,;.!?\r\n])/giu;
25
- const DISTANCE_RE = /(?<amount>\d{1,3}(?:[.,]\d+)?)\s*(?<unit>km|км|min(?:ute)?s?|мин(?:ут(?:ы|а|ах)?)?|дақиқа|daqiqa|метр(?:а|ов)?|m)\s*(?<mode>пешком|пішки|walking?|yayov|piyoda|на\s+машине|by\s+car)?\s*(?:до|от|from|to|до\s+станции)\s+(?<target>[^,;.!?\r\n]{2,96})/giu;
22
+ const RELATION_RE = /(?<relation>рядом\s+(?:с|со)|возле|около|недалеко\s+от|напротив|навпроти|за|перед|позаду|near(?:by)?|close\s+to|next\s+to|opposite|behind|in\s+front\s+of|yaqin(?:ida)?|yonida|ro['’ʻʼ`]?parasida|орналасқан\s+жерде|жанында|қасында|жакын|каршысында|қарсысында|артында|алдында|поруч|біля|поблизу|lângă|aproape\s+de|în\s+apropiere\s+de|vizavi\s+de|în\s+spatele|în\s+fața)\s+(?<target>[^,;.!?\r\n]{2,96})/giu;
23
+ const POSTFIX_RELATION_RE = /(?<target>[^,;.!?\r\n]{2,96}?)\s+(?<relation>yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida|жанында|қасында|жакын|каршысында|қарсысында|артында|алдында)(?=$|[,;.!?\r\n])/giu;
24
+ const DISTANCE_UNIT = String.raw`(?:km|км|min(?:ute)?s?|мин(?:ут(?:ы|а|ах)?)?|хв(?:илин(?:и|у)?)?|дақиқа|daqiqa|метр(?:а|ов|ів)?|m)`;
25
+ const DISTANCE_MODE = String.raw`(?:пешком|пішки|walking?|yayov|piyoda|на\s+машине|by\s+car)`;
26
+ const DISTANCE_RE = new RegExp(String.raw`(?<amount>\d{1,3}(?:[.,]\d+)?)\s*(?<unit>${DISTANCE_UNIT})\s*(?<mode>${DISTANCE_MODE})?\s*(?:до|от|from|to|до\s+станции)\s+(?<target>[^,;.!?\r\n]{2,96})`, 'giu');
27
+ const POSTFIX_DISTANCE_RE = new RegExp(String.raw`(?<target>[^,;.!?\r\n]{2,96}?)\s+(?<amount>\d{1,3}(?:[.,]\d+)?)\s*(?<unit>${DISTANCE_UNIT})(?:\s*(?<mode>${DISTANCE_MODE}))?(?=$|[,;.!?\r\n])`, 'giu');
26
28
 
27
29
  function cleanTarget(value) {
28
30
  return String(value || '')
29
31
  .replace(/\b(?:на\s+машине|пешком|пішки|walking?|piyoda|yayov)\b/giu, ' ')
30
- .replace(/(?<!\p{L})(\p{L}{3,})(?:ga|qa|ka)(?!\p{L})/giu, '$1')
31
32
  .replace(/\s+/g, ' ').trim();
32
33
  }
33
34
 
34
35
  function relationKind(value) {
35
36
  const text = String(value || '').toLowerCase();
36
- if (/напротив|opposite|ro['’ʻʼ`]?parasida/u.test(text)) return 'opposite';
37
- if (/behind|\bза\b/u.test(text)) return 'behind';
38
- if (/in\s+front|\bперед\b/u.test(text)) return 'in_front_of';
37
+ if (/напротив|навпроти|opposite|vizavi|ro['’ʻʼ`]?parasida|каршысында|қарсысында/u.test(text)) return 'opposite';
38
+ if (/behind|за|позаду|în\s+spatele|артында/u.test(text)) return 'behind';
39
+ if (/in\s+front|\bперед\b|în\s+fața|алдында/u.test(text)) return 'in_front_of';
39
40
  return 'near';
40
41
  }
41
42
 
@@ -43,10 +44,30 @@ function markerTypes(value) {
43
44
  return TYPE_MARKERS.filter(([, re]) => re.test(value)).map(([type]) => type);
44
45
  }
45
46
 
47
+ function stripLeadingTypeMarker(value) {
48
+ return String(value || '')
49
+ .replace(/^(?:supermarket|супермаркет|гипермаркет|магазин|shopping\s+(?:mall|cent(?:er|re))|mall|т[цр]|savdo\s+markaz)\s+/iu, '')
50
+ .replace(/\s+/g, ' ').trim();
51
+ }
52
+
53
+ function parentMatchesCity(parentId, city, country) {
54
+ if (!city || !parentId) return true;
55
+ const expected = canonicalCity(city, country);
56
+ if (!expected) return true;
57
+ const parentCities = String(parentId)
58
+ .split(/[:/]/u)
59
+ .map((part) => canonicalCity(part, country))
60
+ .filter(Boolean);
61
+ // A resolver was already given the city scope. Reject only when its stable
62
+ // parent ID identifies a *different known city*; a catalog's local-language
63
+ // slug (e.g. Bucuresti) is a valid alias of the caller's canonical city.
64
+ return parentCities.length === 0 || parentCities.includes(expected);
65
+ }
66
+
46
67
  function normalizeReference(candidate, country, city) {
47
68
  if (!candidate?.id || !candidate?.canonicalName && !candidate?.canonical) return null;
48
69
  if (candidate.country && String(candidate.country).toUpperCase() !== country) return null;
49
- if (city && candidate.parentId && !String(candidate.parentId).toLowerCase().includes(String(city).toLowerCase())) return null;
70
+ if (!parentMatchesCity(candidate.parentId, city, country)) return null;
50
71
  return Object.freeze({
51
72
  id: String(candidate.id),
52
73
  canonical: String(candidate.canonicalName || candidate.canonical),
@@ -60,7 +81,9 @@ function resolveTarget(target, context) {
60
81
  if (typeof context.resolveGeoCandidates !== 'function') return [];
61
82
  const types = markerTypes(target);
62
83
  const query = cleanTarget(target) || String(target).trim();
63
- const bareQuery = types.includes('metro') ? query.replace(/\b(?:metrosi|metro|метро|м\.)\b/giu, ' ').replace(/\s+/g, ' ').trim() : query;
84
+ const bareQuery = types.includes('metro')
85
+ ? query.replace(/\b(?:metrosi|metro|метро|м\.)\b/giu, ' ').replace(/\s+/g, ' ').trim()
86
+ : stripLeadingTypeMarker(query);
64
87
  for (const currentQuery of [...new Set([query, bareQuery])].filter((item) => item.length >= 2)) {
65
88
  const resolved = context.resolveGeoCandidates(Object.freeze({
66
89
  country: context.country,
@@ -80,7 +103,7 @@ function distanceDetails(groups) {
80
103
  if (!Number.isFinite(amount) || amount <= 0) return {};
81
104
  const unit = String(groups.unit || '').toLowerCase();
82
105
  if (/^(?:km|км)$/u.test(unit)) return { distanceMeters: Math.round(amount * 1000) };
83
- if (/^(?:min|мин|дақиқа|daqiqa)/u.test(unit)) {
106
+ if (/^(?:min|мин|хв|дақиқа|daqiqa)/u.test(unit)) {
84
107
  const mode = /пешком|пішки|walking|yayov|piyoda/iu.test(groups.mode || '') ? 'walk'
85
108
  : /машине|by\s+car/iu.test(groups.mode || '') ? 'drive' : 'unknown';
86
109
  return { durationMinutes: Math.round(amount), mode };
@@ -96,7 +119,7 @@ export function extractHousingPoiRelations(value, { country = '', city = '', res
96
119
  if (!text || !normalizedCountry || typeof resolveGeoCandidates !== 'function') return deepFreeze([]);
97
120
  const context = { country: normalizedCountry, city, resolveGeoCandidates };
98
121
  const relations = [];
99
- for (const pattern of [RELATION_RE, POSTFIX_RELATION_RE, DISTANCE_RE]) {
122
+ for (const pattern of [RELATION_RE, POSTFIX_RELATION_RE, DISTANCE_RE, POSTFIX_DISTANCE_RE]) {
100
123
  for (const match of text.matchAll(pattern)) {
101
124
  const groups = match.groups || {};
102
125
  const target = groups.target || '';
@@ -87,7 +87,7 @@ export function parseHousingRoomCount(value) {
87
87
  if (total >= 1 && total <= 20) return total;
88
88
  }
89
89
  for (const [re, rooms] of NUMBER_WORDS) if (re.test(text)) return rooms;
90
- const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:(?:-\s*)?комнат\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li)?|(?:-\s*)?хона(?:лик|ли)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
90
+ const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:ta\s*)?(?:(?:-\s*)?комнат\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li|si|lari)?|(?:-\s*)?хона(?:лик|ли|си|лари)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
91
91
  if (numeric) {
92
92
  const rooms = toNumber(numeric[1]);
93
93
  if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
package/src/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from './lexicon-core.js';
2
2
  export * from './normalization.js';
3
+ // Both normalization and hiring-skills expose an internal regex escaper. The
4
+ // root API deliberately chooses the generic normalization helper instead of
5
+ // letting ESM's ambiguous star-export rule silently omit it.
3
6
  export { escapeRegex } from './normalization.js';
4
7
  export * from './parser-core.js';
5
8
  export * from './temporal.js';
package/src/money-core.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aliasesOf, findCanonical } from './normalization.js';
1
+ import { aliasesOf, escapeRegex, findCanonical } from './normalization.js';
2
2
  import {
3
3
  CURRENCY_SYMBOL_CANDIDATES,
4
4
  CURRENCY_TERMS,
@@ -106,10 +106,6 @@ export function moneyCurrencyFromText(value, fallbackCurrency = null) {
106
106
  return candidates[0];
107
107
  }
108
108
 
109
- function escapeRegex(value) {
110
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
111
- }
112
-
113
109
  export function moneyCurrencyPattern() {
114
110
  return [...new Set(CURRENCY_TERMS.flatMap((entry) => [entry.canonical, ...aliasesOf(entry)]).filter(Boolean))]
115
111
  .sort((a, b) => String(b).length - String(a).length)
@@ -6,6 +6,7 @@ const AMBIGUOUS_TASHKENT_COMPLEXES = new Set([
6
6
  'City Mall',
7
7
  'Darhan',
8
8
  'Pushkin',
9
+ 'Seoul Mun',
9
10
  ]);
10
11
 
11
12
  function complex(name, aliases = []) {
@@ -188,6 +189,10 @@ export const TASHKENT_RESIDENTIAL_COMPLEXES = Object.freeze([
188
189
  complex('Sayram Avenue'),
189
190
  complex('Sayram Plaza'),
190
191
  complex('Sayram Tower'),
192
+ // The development contains both a residential complex and a commercial
193
+ // Seoul Mun destination. Keep the housing entity, but require an explicit
194
+ // residential marker so a landmark mention is never promoted to an address.
195
+ complex('Seoul Mun', ['Seul Mun', 'Seoul Moon', 'Seul Moon', 'Сеул Мун']),
191
196
  complex('Seoul Riverside'),
192
197
  complex('Shahriabad'),
193
198
  complex('Shohsaroy Towers'),
package/src/temporal.d.ts CHANGED
@@ -2,6 +2,22 @@ import type { ParseCandidate } from './parser-core.js';
2
2
  export type TemporalContext = Readonly<{ domain?: 'real-estate' | 'vacancy'; countryCode?: string; cityId?: string; locale?: string; referenceDate?: Date | string; publishedAt?: Date | string; fetchedAt?: Date | string; source?: string }>;
3
3
  export type CalendarDateValue = Readonly<{ year: number; month: number; day: number }>;
4
4
  export type TimeValue = Readonly<{ hour: number; minute: number }>;
5
+ export type TimeRangeValue = Readonly<{ start: TimeValue; end: TimeValue; crossesMidnight: boolean }>;
5
6
  export type DurationValue = Readonly<{ value: number; unit: 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; bound: 'exact' | 'min' | 'max' }>;
7
+ export type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
8
+ export type WorkScheduleValue = Readonly<{
9
+ type: 'cycle' | 'weekdays' | 'custom' | 'flexible';
10
+ workDays?: number;
11
+ restDays?: number;
12
+ cycleHours?: Readonly<{
13
+ work: number;
14
+ rest: number;
15
+ }>;
16
+ daysOffMode?: 'fixed' | 'floating' | 'rotating';
17
+ workingDays?: readonly Weekday[];
18
+ workingHours?: TimeRangeValue;
19
+ }>;
20
+ export type ShiftValue = Readonly<{ name?: string; hours: TimeRangeValue }>;
21
+ export type TemporalData = Readonly<Record<string, CalendarDateValue | DurationValue | TimeValue | TimeRangeValue | WorkScheduleValue | readonly ShiftValue[] | unknown>>;
6
22
  export function extractTemporalCandidates(value: unknown, context?: TemporalContext): readonly ParseCandidate[];
7
- export function parseTemporal(value: unknown, context?: TemporalContext): Readonly<{ data: Readonly<Record<string, CalendarDateValue | DurationValue | TimeValue | unknown>>; confidence: Readonly<Record<string, number>>; debug: Readonly<{ candidates: readonly ParseCandidate[]; discardedCandidates: readonly ParseCandidate[]; refinersApplied: readonly string[] }> }>;
23
+ export function parseTemporal(value: unknown, context?: TemporalContext): Readonly<{ data: TemporalData; confidence: Readonly<Record<string, number>>; debug: Readonly<{ candidates: readonly ParseCandidate[]; discardedCandidates: readonly ParseCandidate[]; refinersApplied: readonly string[] }> }>;
package/src/temporal.js CHANGED
@@ -6,58 +6,107 @@ const DATE_WORD_RE = new RegExp(`(?<![\\p{L}\\p{N}])(\\d{1,2})\\s+(${MONTH_NAMES
6
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
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
8
  const DATE_NUMERIC_PARTIAL_RE = /(?<![\d.])(\d{1,2})[./](\d{1,2})(?![\d.])/u;
9
- const TIME_RE = /(?<![\d.:])(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm|утра|вечера)?(?![\d.:])/iu;
10
- const TIME_RANGE_RE = /(?:с|from)?\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:до|to|-|–|—)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/iu;
11
- const DURATION_RE = /(?<![\p{L}\p{N}])(?:(от|минимум|не\s+менее|до|не\s+более|на)(?:\s+на)?\s*)?(полгода|год|месяц(?:а|ев)?|мес\.?|недел[ьяи]|дн(?:я|ей)?|день|час(?:а|ов)?|мин(?:ут[аы]?|\.)?|hours?|minutes?|months?|weeks?|days?|years?)(?:\s*)?(\d+(?:[.,]\d+)?)?(?![\p{L}\p{N}])/iu;
12
- const DURATION_NUMBER_FIRST_RE = /(?<![\p{L}\p{N}])(?:(от|минимум|не\s+менее|до|не\s+более|на)(?:\s+на)?\s*)?(\d+(?:[.,]\d+)?)(?:-?х)?\s*(месяц(?:а|ев)?|мес\.?|недел[ьяи]|дн(?:я|ей)?|день|час(?:а|ов)?|мин(?:ут[аы]?|\.)?|hours?|minutes?|months?|weeks?|days?|years?)(?![\p{L}\p{N}])/iu;
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');
13
16
  const WEEKDAYS = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
14
- const DAY_ALIASES = Object.freeze({ пн: 0, понедельник: 0, понедельника: 0, mon: 0, monday: 0, вт: 1, вторник: 1, вторника: 1, tue: 1, tuesday: 1, ср: 2, среда: 2, среды: 2, wed: 2, wednesday: 2, чт: 3, четверг: 3, четверга: 3, thu: 3, thursday: 3, пт: 4, пятница: 4, пятницы: 4, fri: 4, friday: 4, сб: 5, суббота: 5, субботы: 5, sat: 5, saturday: 5, вс: 6, воскресенье: 6, воскресенья: 6, sun: 6, sunday: 6 });
15
- const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+)?(сегодня|завтра|послезавтра|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
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+)?(сегодня|завтра|послезавтра|сьогодні|післязавтра|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;
16
28
  const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
17
29
  const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
18
- const NEXT_WEEKDAY_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:со?\\s+следующ(?:его|ей)\\s+|next\\s+)(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
19
- const END_OF_MONTH_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:до|until|available\\s+until)\\s+(?:конца|end\\s+of)\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'giu');
20
- const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|from)\s+(?:начала\s+месяца|start\s+of\s+(?:the\s+)?month)(?![\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)(?![\p{L}\p{N}])/giu;
21
33
  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;
22
34
 
23
- function referenceDate(context = {}) { return new Date(context.referenceDate || context.publishedAt || context.fetchedAt || Date.now()); }
35
+ function referenceEntry(context = {}) {
36
+ for (const [source, value] of [['referenceDate', context.referenceDate], ['publishedAt', context.publishedAt], ['fetchedAt', context.fetchedAt]]) {
37
+ if (value == null || value === '') continue;
38
+ const date = new Date(value);
39
+ if (Number.isFinite(date.getTime())) return Object.freeze({ date, source });
40
+ }
41
+ return Object.freeze({ date: new Date(), source: 'currentDate' });
42
+ }
43
+ function referenceDate(context = {}) { return referenceEntry(context).date; }
24
44
  function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
25
45
  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; }
26
46
  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 }); }
27
- function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return /(?:с|from|доступна\s+с|заезд\s)/iu.test(before) ? 'from' : /(?:до|until|не\s+позднее)/iu.test(before) ? 'until' : 'exact'; }
47
+ function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return /(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))/iu.test(before) ? 'from' : /(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)/iu.test(before) ? 'until' : 'exact'; }
28
48
  function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (relation === 'from' && candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; if (relation === 'until' && candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
29
- function parseClock(raw) { const match = String(raw).trim().match(/^(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm|утра|вечера)?$/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 (/вечера/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
30
- function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year/u.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month/u.test(normalized) ? 'month' : /нед|week/u.test(normalized) ? 'week' : /дн|день|day/u.test(normalized) ? 'day' : /мин|minute/u.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее/iu.test(prefix || '') ? 'min' : /до|не\s+более/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
31
- function semanticDurationType(text, start) {
49
+ 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; }
50
+ 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 }); }
51
+ function semanticDurationType(text, start, duration) {
32
52
  const before = text.slice(Math.max(0, start - 56), start);
33
53
  const after = text.slice(start, Math.min(text.length, start + 56));
34
54
  if (/(?:испытательн(?:ый)?\s+срок|probation)[^.;\n]{0,30}$/iu.test(before)) return 'probationDuration';
35
55
  if (/(?:контракт|contract)[^.;\n]{0,30}$/iu.test(before)) return 'contractDuration';
36
- if (/(?:сда[её]т|квартир|аренд|ijara|rent)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent)/iu.test(after)) return 'minimumRentalDuration';
56
+ if (/(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)/iu.test(after)) {
57
+ if (duration?.bound === 'max') return 'maximumRentalDuration';
58
+ if (duration?.bound === 'exact') return 'fixedRentalDuration';
59
+ return 'minimumRentalDuration';
60
+ }
37
61
  return 'duration';
38
62
  }
39
63
  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()); }
40
- function relativeDays(raw) { const lower = raw.toLowerCase(); if (/сегодня|today/u.test(lower)) return 0; if (/завтра|tomorrow/u.test(lower)) return 1; if (/послезавтра|day\s+after/u.test(lower)) return 2; 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; }
41
- function temporalContextType(text, start, context) { const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48)); if (/(?:свобод|доступ|заезд|заезж|move[- ]?in|available)/iu.test(around)) return 'availabilityDate'; if ((context.domain === 'vacancy' || /(?:ваканс|работ|job)/iu.test(around)) && /(?:выход|start|приступ)/iu.test(around)) return 'startDate'; return 'relativeDate'; }
64
+ 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; }
65
+ function relativeDurationDays(amount, unit) {
66
+ const value = Number(amount);
67
+ if (!Number.isFinite(value) || value < 0) return null;
68
+ return /(?:тиж|săptăm|saptaman|апта|жума)/iu.test(unit) ? value * 7 : value;
69
+ }
70
+ 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'; }
42
71
  function dateEntityType(text, start, context, relation = relationNear(text, start)) {
43
72
  const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48));
44
73
  if (/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)/iu.test(around)) return 'deadline';
45
74
  if (/(?:published|publication|опубликован|опублікован)/iu.test(around)) return 'publicationDate';
46
- if (relation === 'until' || /(?:свобод|доступ|заезд|заезж|move[- ]?in|available)/iu.test(around) && /(?:до|until)/iu.test(around)) return 'availabilityUntil';
47
- if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
75
+ // Direction must belong to this date's local left context. Looking ahead
76
+ // across a whole listing makes "available from 12 February, until March"
77
+ // incorrectly classify the February date as an end date.
78
+ if (relation === 'until') return 'availabilityUntil';
79
+ if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|вільн|disponibil|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
48
80
  return 'calendarDate';
49
81
  }
50
- function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: context.referenceDate ? 'referenceDate' : context.publishedAt ? 'publishedAt' : context.fetchedAt ? 'fetchedAt' : 'currentDate' }] : []; }
82
+ function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: referenceEntry(context).source }] : []; }
83
+ const SCHEDULE_CONTEXT_RE = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job|графік|змін[аи]|program(?:ul)?\s+de\s+lucru|ish\s+grafigi|жұмыс\s+кестесі|жумуш\s+графиги)/iu;
84
+ function scheduleDaysOffMode(text) {
85
+ if (/(?:плавающ|floating|flexible\s+days\s+off)/iu.test(text)) return 'floating';
86
+ if (/(?:скользящ|сменн|rotating\s+days\s+off|выходные\s+по\s+графику)/iu.test(text)) return 'rotating';
87
+ return 'fixed';
88
+ }
89
+ function cycleScheduleValue(work, rest, daysOffMode) {
90
+ // Day cycles such as 2/2 and 5/2 are common. Values larger than a week in
91
+ // an explicit schedule context are the conventional shift notation 24/48
92
+ // or 12/24, not a claim of dozens of working days.
93
+ if (Math.max(work, rest) > 7) {
94
+ // An hour cycle has no fixed weekday rest days. Keep an explicitly
95
+ // floating mode, otherwise expose the inherent repeating rotation.
96
+ return Object.freeze({ type: 'cycle', cycleHours: Object.freeze({ work, rest }), daysOffMode: daysOffMode === 'fixed' ? 'rotating' : daysOffMode });
97
+ }
98
+ return Object.freeze({ type: 'cycle', workDays: work, restDays: rest, daysOffMode });
99
+ }
51
100
  function isTimeRangeContextual(match, text) {
52
- if (/[.:]|\b(?:am|pm|утра|вечера)\b/iu.test(match[0])) return true;
101
+ if (/[.:]|\b(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)\b/iu.test(match[0])) return true;
53
102
  if (/^\s*(?:с|from)\b/iu.test(match[0])) return true;
54
103
  const start = match.index ?? 0;
55
- return /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job)/iu.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
104
+ return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
56
105
  }
57
106
  function isClockContextual(match, text) {
58
- if (/(?:am|pm|утра|вечера)/iu.test(match[0])) return true;
107
+ if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
59
108
  const start = match.index ?? 0;
60
- return /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job)/iu.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
109
+ return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
61
110
  }
62
111
  function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
63
112
  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); }
@@ -93,15 +142,23 @@ export function extractTemporalCandidates(value, context = {}) {
93
142
  }
94
143
  for (const match of text.matchAll(START_OF_MONTH_RE)) {
95
144
  const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
96
- 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: context.referenceDate ? 'referenceDate' : context.publishedAt ? 'publishedAt' : context.fetchedAt ? 'fetchedAt' : 'currentDate' }]));
145
+ 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 }]));
97
146
  }
98
147
  for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
99
148
  const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
100
- 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: context.referenceDate ? 'referenceDate' : context.publishedAt ? 'publishedAt' : context.fetchedAt ? 'fetchedAt' : 'currentDate' }]));
149
+ 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 }]));
150
+ }
151
+ 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 }])); }
152
+ for (const match of text.matchAll(EXTENDED_RELATIVE_RE)) {
153
+ const amount = match[1] || match[3] || match[5];
154
+ const unit = match[2] || match[4] || match[6];
155
+ const days = relativeDurationDays(amount, unit);
156
+ if (days == null) continue;
157
+ const type = temporalContextType(text, match.index ?? 0, context);
158
+ 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 }]));
101
159
  }
102
- 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: context.referenceDate ? 'referenceDate' : context.publishedAt ? 'publishedAt' : context.fetchedAt ? 'fetchedAt' : 'currentDate' }])); }
103
- 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, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
104
- for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) { if (match[3]) continue; const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
160
+ 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] }] : [])])); }
161
+ for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) { if (match[3]) continue; 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] }] : [])])); }
105
162
  const timeRangeSpans = [];
106
163
  for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
107
164
  const start = parseClock(match[1]); const end = parseClock(match[2]);
@@ -114,14 +171,17 @@ export function extractTemporalCandidates(value, context = {}) {
114
171
  if (timeRangeSpans.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd) || !isClockContextual(match, text)) continue;
115
172
  const clock = parseClock(match[0]); if (clock) candidates.push(candidate('clockTime', clock, match, 'temporal.clock-time', .94, [{ type: 'clock', value: 'explicit' }]));
116
173
  }
117
- const scheduleContext = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job)/iu;
174
+ const scheduleContext = SCHEDULE_CONTEXT_RE;
118
175
  for (const match of text.matchAll(/(?<![\d:.])(\d{1,2})\s*(?:\/|\\|через|-)\s*(\d{1,2})(?![\d:.])/giu)) {
119
176
  const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
120
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: Number(match[1]), restDays: Number(match[2]), daysOffMode: /плавающ|скользящ|сменн/iu.test(around) ? 'rotating' : 'fixed' }), match, 'temporal.schedule-cycle', .99, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }]));
177
+ const daysOffMode = scheduleDaysOffMode(around);
178
+ const work = Number(match[1]); const rest = Number(match[2]);
179
+ 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 }]));
121
180
  }
122
181
  for (const match of text.matchAll(/(?<![\p{L}\p{N}])два\s+через\s+два(?![\p{L}\p{N}])/giu)) {
123
182
  const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
124
- candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: 2, restDays: 2, daysOffMode: /плавающ|скользящ|сменн/iu.test(around) ? 'rotating' : 'fixed' }), match, 'temporal.schedule-cycle.words', .97, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }]));
183
+ const daysOffMode = scheduleDaysOffMode(around);
184
+ 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 }]));
125
185
  }
126
186
  for (const match of text.matchAll(WEEKDAY_RANGE_RE)) {
127
187
  const start = DAY_ALIASES[match[1].toLowerCase()]; const end = DAY_ALIASES[match[2].toLowerCase()];
@@ -87,7 +87,7 @@ export const UA_METRO_LOCATION_EXTENSIONS = Object.freeze({
87
87
  ['Palats Sportu', 'Палац Спорту', 'Дворец Спорта'],
88
88
  ['Akademika Pavlova', 'Академіка Павлова', 'Академика Павлова', 'Ак. Павлова', 'Ак Павлова'],
89
89
  ['Studentska', 'Студентська', 'Студенческая'],
90
- ['Saltivska', 'Heroiv Pratsi', 'Салтівська', 'Героїв Праці', 'Героев Труда'],
90
+ ['Saltivska', 'Heroiv Pratsi', 'Салтівська', 'Героїв Праці', 'Героев Труда', 'Героев Праці', 'Героев праци'],
91
91
  ['Peremoha', 'Перемога', 'Победа'],
92
92
  ['Oleksiivska', 'Олексіївська', 'Алексеевская'],
93
93
  ['Naukova', 'Наукова', 'Научная'],
@@ -104,4 +104,4 @@ export const UA_METRO_LOCATION_EXTENSIONS = Object.freeze({
104
104
  ['Metrobudivnykiv', 'Метробудівників', 'Метростроителей'],
105
105
  ]),
106
106
  }),
107
- });
107
+ });