@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 +1 -1
- package/src/contact.js +14 -0
- package/src/geography-detection.js +1 -3
- package/src/hiring-ats.d.ts +1 -1
- package/src/hiring-ats.js +1 -4
- package/src/hiring-skills.js +5 -4
- package/src/housing-address.js +46 -32
- package/src/housing-listing-enrichment.js +51 -6
- package/src/housing-listing-fields.js +3 -1
- package/src/housing-money.d.ts +1 -1
- package/src/housing-money.js +30 -6
- package/src/housing-poi-relations.js +37 -14
- package/src/housing-structured.js +1 -1
- package/src/index.js +3 -0
- package/src/money-core.js +1 -5
- package/src/tashkent-residential-complexes.js +5 -0
- package/src/temporal.d.ts +17 -1
- package/src/temporal.js +92 -32
- package/src/ua-location-extensions-metro.js +2 -2
package/package.json
CHANGED
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)))];
|
package/src/hiring-ats.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 })] : [];
|
package/src/hiring-skills.js
CHANGED
|
@@ -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+')
|
package/src/housing-address.js
CHANGED
|
@@ -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}*|ор[-–—]
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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)
|
|
434
|
+
if (postfixTyped) {
|
|
435
|
+
add(postfixTyped);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
431
438
|
|
|
432
439
|
const prefixTyped = prefixTypedStreetAddress(line);
|
|
433
|
-
if (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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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) {
|
package/src/housing-money.d.ts
CHANGED
|
@@ -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
|
|
package/src/housing-money.js
CHANGED
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
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 (
|
|
37
|
-
if (/behind
|
|
38
|
-
if (/in\s+front|\bперед\b
|
|
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 (
|
|
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')
|
|
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
|
|
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*)?хона(
|
|
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:
|
|
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
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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({
|
|
15
|
-
|
|
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+(
|
|
20
|
-
const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(
|
|
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
|
|
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 /(
|
|
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(
|
|
30
|
-
function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year/
|
|
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))
|
|
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 (
|
|
41
|
-
function
|
|
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
|
-
|
|
47
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
107
|
+
if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
|
|
59
108
|
const start = match.index ?? 0;
|
|
60
|
-
return
|
|
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.
|
|
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.
|
|
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(
|
|
103
|
-
for (const match of text.matchAll(new RegExp(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|