@whiteslove/parsing-lexicon 0.8.0 → 0.8.2
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/central-asia-locations.js +4 -1
- package/src/contact.js +41 -6
- package/src/geography-central-asia.js +13 -4
- package/src/geography-detection.js +27 -6
- package/src/geography.js +3 -1
- package/src/hiring-ats.js +5 -1
- package/src/hiring-context.js +18 -3
- package/src/hiring-requirements.d.ts +3 -0
- package/src/hiring-requirements.js +30 -2
- package/src/hiring-skills.js +3 -2
- package/src/hiring-source-semantics.js +5 -5
- package/src/housing-address.js +19 -5
- package/src/housing-listing-enrichment.d.ts +3 -0
- package/src/housing-listing-enrichment.js +22 -3
- package/src/housing-listing-fields.js +34 -0
- package/src/housing-money.d.ts +2 -1
- package/src/housing-money.js +50 -6
- package/src/housing-structured.js +1 -1
- package/src/tashkent-housing-geography.js +17 -9
- package/src/tashkent-residential-complexes.js +1 -0
- package/src/temporal.js +31 -6
package/package.json
CHANGED
|
@@ -66,7 +66,10 @@ function explicitCityFromText(text, countryCode) {
|
|
|
66
66
|
const start = match.index || 0;
|
|
67
67
|
const before = value.slice(Math.max(0, start - 40), start);
|
|
68
68
|
const after = value.slice(start + match[0].length, start + match[0].length + 48);
|
|
69
|
-
|
|
69
|
+
// Kept in sync with geography-detection.js's CITY_CONTEXT_RE breadth
|
|
70
|
+
// (shahr/шаар/город/city/viloyat/област forms) plus the Xonobod-
|
|
71
|
+
// specific Andijan-region markers this catalog also relies on.
|
|
72
|
+
if (!/(?:shahr(?:i)?|шаар(?:ы|ында|ына|ынан)?|город(?:а|е|у|ом)?|city|viloyat(?:i)?|област\p{L}{0,4}|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
|
|
70
73
|
}
|
|
71
74
|
matches.push({ item, length: normalizeForMatch(match[0]).length });
|
|
72
75
|
}
|
package/src/contact.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parsePhoneNumberFromString } from 'libphonenumber-js/min';
|
|
2
|
+
import { moneyCurrencyPattern } from './money-core.js';
|
|
2
3
|
|
|
3
4
|
// Broad phone-like detection used by other parsers for exclusion/classification.
|
|
4
5
|
// It deliberately stays tolerant and does not validate against a country plan.
|
|
@@ -6,20 +7,50 @@ const PHONE_LIKE_RE = /\+?\d(?:[\t \u00a0().-]*\d){9,}/g;
|
|
|
6
7
|
|
|
7
8
|
// Contact extraction may start from shorter national formats, but candidates are
|
|
8
9
|
// only returned after libphonenumber validation.
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const PHONE_EXTENSION_ALTERNATION = 'ext\\.?|extension|x|доб\\.?|дод\\.?';
|
|
11
|
+
const PHONE_CANDIDATE_RE = new RegExp(`\\+?\\d(?:[\\t \\u00a0().-]*\\d){6,}(?:[\\t \\u00a0]*(?:${PHONE_EXTENSION_ALTERNATION})\\s*\\d{1,6})?`, 'giu');
|
|
12
|
+
const PHONE_EXTENSION_RE = new RegExp(`[\\t \\u00a0]*(?:${PHONE_EXTENSION_ALTERNATION})\\s*(\\d{1,6})$`, 'iu');
|
|
11
13
|
const DATE_LIKE_PHONE_RE = /^\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})(?:\s+\d{1,2})?$/u;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
const PRICE_LABEL_BEFORE_NUMBER_RE = /(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\s+плата)?|rent)\s*[:=\-–—]?\s*$/iu;
|
|
15
|
+
// A currency term/symbol adjacent to a hyphenated digit span is evidence of a
|
|
16
|
+
// price range even without an explicit label word ("50000-60000 сум"); a bare
|
|
17
|
+
// phone number never carries one. Both sides are fully bounded (unlike
|
|
18
|
+
// housing-money.js's number-adjacent variant) since this only scans nearby
|
|
19
|
+
// window text, not text touching the digits themselves.
|
|
20
|
+
const CURRENCY_TERM_NEARBY_RE = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${moneyCurrencyPattern()})(?![\\p{L}\\p{N}_])`, 'iu');
|
|
21
|
+
|
|
22
|
+
// Real Telegram usernames must start with a letter (Telegram itself rejects
|
|
23
|
+
// a digit-led one), so a digit-led "handle" like "@12345_promo" is more
|
|
24
|
+
// likely an order/SKU code than a contact.
|
|
25
|
+
const TELEGRAM_USERNAME_RE = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
|
|
14
26
|
const TELEGRAM_LINK_RE = /(?:https?:\/\/)?(?:t\.me|telegram\.me|telegram\.dog)\/([A-Za-z0-9_]{5,32})(?:\/[0-9]+)?(?:[/?#][^\s]*)?/giu;
|
|
15
27
|
const TELEGRAM_TG_RE = /tg:\/\/resolve\?[^\s]*?\bdomain=([A-Za-z0-9_]{5,32})\b[^\s]*/giu;
|
|
16
28
|
const TELEGRAM_MENTION_RE = /(^|[^\p{L}\p{N}_@])@([A-Za-z0-9_]{5,32})\b/gu;
|
|
29
|
+
// Reserved t.me path segments (joinchat/share/... carry no real handle) and
|
|
30
|
+
// app-name mentions people write as "@Telegram"/"@WhatsApp" — neither is a
|
|
31
|
+
// contactable personal username.
|
|
32
|
+
const RESERVED_TELEGRAM_NAME_RE = /^(?:joinchat|share|addstickers|addtheme|addemoji|confirmphone|login|proxy|socks|iv|s|boost|giftcode|setlanguage|telegram|whatsapp|viber|instagram|facebook)$/iu;
|
|
17
33
|
|
|
18
34
|
function normalizedCountryHint(value) {
|
|
19
35
|
const country = String(value || '').trim().toUpperCase();
|
|
20
36
|
return /^[A-Z]{2}$/.test(country) ? country : undefined;
|
|
21
37
|
}
|
|
22
38
|
|
|
39
|
+
// PHONE_LIKE_RE deliberately accepts punctuation-separated digit sequences.
|
|
40
|
+
// That also resembles a grouped monetary range such as
|
|
41
|
+
// "Narxi: 950 000 - 1.000.000". A nearby explicit price label is stronger
|
|
42
|
+
// semantic evidence than the broad (unvalidated) ten-digit phone mask, so do
|
|
43
|
+
// not hide that span before the money candidate parser sees it. This does
|
|
44
|
+
// not weaken validated national-phone parsing below.
|
|
45
|
+
function isExplicitPriceSpan(text, start, raw) {
|
|
46
|
+
if (!/[\-–—]/u.test(raw)) return false;
|
|
47
|
+
const before = text.slice(Math.max(0, start - 48), start);
|
|
48
|
+
if (PRICE_LABEL_BEFORE_NUMBER_RE.test(before)) return true;
|
|
49
|
+
const end = start + raw.length;
|
|
50
|
+
const after = text.slice(end, Math.min(text.length, end + 24));
|
|
51
|
+
return CURRENCY_TERM_NEARBY_RE.test(before) || CURRENCY_TERM_NEARBY_RE.test(after);
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
function splitPhoneExtension(raw) {
|
|
24
55
|
const match = String(raw || '').match(PHONE_EXTENSION_RE);
|
|
25
56
|
if (!match) return { base: String(raw || '').trim(), extension: null };
|
|
@@ -38,6 +69,7 @@ export function findPhoneLikeSpans(value, options = {}) {
|
|
|
38
69
|
const digits = raw.replace(/\D/g, '');
|
|
39
70
|
if (digits.length < 10) continue;
|
|
40
71
|
const start = match.index ?? 0;
|
|
72
|
+
if (isExplicitPriceSpan(text, start, raw)) continue;
|
|
41
73
|
spans.push(Object.freeze({
|
|
42
74
|
start,
|
|
43
75
|
end: start + raw.length,
|
|
@@ -140,7 +172,7 @@ export function normalizePhone(value, options = {}) {
|
|
|
140
172
|
|
|
141
173
|
function telegramContact(username, raw, start, source) {
|
|
142
174
|
const normalized = String(username || '').replace(/^@/, '');
|
|
143
|
-
if (!TELEGRAM_USERNAME_RE.test(normalized)) return null;
|
|
175
|
+
if (!TELEGRAM_USERNAME_RE.test(normalized) || RESERVED_TELEGRAM_NAME_RE.test(normalized)) return null;
|
|
144
176
|
return Object.freeze({
|
|
145
177
|
start,
|
|
146
178
|
end: start + raw.length,
|
|
@@ -200,7 +232,10 @@ export function parsePrimaryContact(value) {
|
|
|
200
232
|
}
|
|
201
233
|
// Bounded like the `trailing` keyword below: "тел"/"phone" must be a whole
|
|
202
234
|
// word, not a suffix of an unrelated word ("хостел", "котел").
|
|
203
|
-
|
|
235
|
+
// Widened beyond bare stems to cover the conjugated imperative forms
|
|
236
|
+
// ('Звоните', 'Позвоните', 'Наберите', 'Дзвоніть') that are the actual
|
|
237
|
+
// everyday phrasing in CIS classifieds — the bare stems alone missed them.
|
|
238
|
+
const keyword = text.match(/(?<![\p{L}\p{N}_])(?:tel|тел|phone|моб|whats?app|viber|telegram|(?:по|пере|за)?звонит\p{L}*|(?:за|під)?дзвоніть\p{L}*|звоніть\p{L}*|наберит\p{L}*|номер\p{L}*|aloqa|byla|contact)(?![\p{L}\p{N}_])[^\d+]{0,20}(\+?\d[\d\s().-]{6,}\d)/iu);
|
|
204
239
|
if (keyword) {
|
|
205
240
|
const digits = keyword[1].replace(/\D/g, '');
|
|
206
241
|
if (digits.length >= 9 && digits.length <= 15) return keyword[1].trim();
|
|
@@ -76,7 +76,10 @@ const KZ_BASE_CITIES = Object.freeze([
|
|
|
76
76
|
entity('Karaganda', { kk: ['Қарағанды'], ru: ['Караганда'], en: ['Karaganda', 'Qaragandy'] }, { country: 'KZ' }),
|
|
77
77
|
entity('Aktobe', { kk: ['Ақтөбе'], ru: ['Актобе'], en: ['Aktobe', 'Aqtobe'] }, { country: 'KZ' }),
|
|
78
78
|
entity('Atyrau', { kk: ['Атырау'], ru: ['Атырау'], en: ['Atyrau'] }, { country: 'KZ' }),
|
|
79
|
-
|
|
79
|
+
// "Oral" is an ordinary English word ("an oral agreement"); "Уральск"/
|
|
80
|
+
// "Uralsk" are unambiguous and stay unguarded, only the short aliases need
|
|
81
|
+
// nearby city context.
|
|
82
|
+
entity('Oral', { kk: ['Орал'], ru: ['Уральск', 'Орал'], en: ['Oral', 'Uralsk'] }, { country: 'KZ', contextRequiredAliases: ['Oral', 'Орал'] }),
|
|
80
83
|
entity('Taraz', { kk: ['Тараз'], ru: ['Тараз', 'Джамбул'], en: ['Taraz'] }, { country: 'KZ' }),
|
|
81
84
|
entity('Pavlodar', { kk: ['Павлодар'], ru: ['Павлодар'], en: ['Pavlodar'] }, { country: 'KZ' }),
|
|
82
85
|
entity('Semey', { kk: ['Семей'], ru: ['Семей', 'Семипалатинск'], en: ['Semey', 'Semipalatinsk'] }, { country: 'KZ' }),
|
|
@@ -148,7 +151,10 @@ export const KZ_CITY_ADDITIONS = Object.freeze([
|
|
|
148
151
|
catalogCity('Kurchatov', { kk: ['Курчатов'], ru: ['Курчатов'], en: ['Kurchatov'] }, { country: 'KZ', priority: 'P4' }),
|
|
149
152
|
catalogCity('Ayagoz', { kk: ['Аягөз'], ru: ['Аягоз'], en: ['Ayagoz'] }, { country: 'KZ', priority: 'P4' }),
|
|
150
153
|
catalogCity('Khromtau', { kk: ['Хромтау'], ru: ['Хромтау'], en: ['Khromtau'] }, { country: 'KZ', priority: 'P4' }),
|
|
151
|
-
|
|
154
|
+
// "Alga" (a common Kazakh exclamation, "forward!") and "alga" (the English
|
|
155
|
+
// word for algae) collide badly in ordinary prose, with no unambiguous
|
|
156
|
+
// longer alias available.
|
|
157
|
+
catalogCity('Alga', { kk: ['Алға'], ru: ['Алга'], en: ['Alga'] }, { country: 'KZ', priority: 'P4', contextRequired: true }),
|
|
152
158
|
catalogCity('Kandyagash', { kk: ['Қандыағаш'], ru: ['Кандыагаш'], en: ['Kandyagash'] }, { country: 'KZ', priority: 'P4' }),
|
|
153
159
|
catalogCity('Shalkar', { kk: ['Шалқар'], ru: ['Шалкар'], en: ['Shalkar'] }, { country: 'KZ', priority: 'P4' }),
|
|
154
160
|
catalogCity('Kulsary', { kk: ['Құлсары'], ru: ['Кульсары'], en: ['Kulsary'] }, { country: 'KZ', priority: 'P4' }),
|
|
@@ -160,9 +166,12 @@ export const KZ_CITY_ADDITIONS = Object.freeze([
|
|
|
160
166
|
catalogCity('Zhitikara', { kk: ['Жітіқара'], ru: ['Житикара'], en: ['Zhitikara'] }, { country: 'KZ', priority: 'P4' }),
|
|
161
167
|
catalogCity('Aksai', { kk: ['Ақсай'], ru: ['Аксай'], en: ['Aksai'] }, { country: 'KZ', priority: 'P4' }),
|
|
162
168
|
catalogCity('Baikonur', { kk: ['Байқоңыр'], ru: ['Байконур'], en: ['Baikonur'] }, { country: 'KZ', priority: 'P4', type: 'special_status_city' }),
|
|
163
|
-
|
|
169
|
+
// "Aral"/"Арал" collide with "Aral Sea" mentions; "Аральск"/"Aralsk" stay
|
|
170
|
+
// unguarded since they're unambiguous.
|
|
171
|
+
catalogCity('Aral', { kk: ['Арал'], ru: ['Аральск', 'Арал'], en: ['Aral', 'Aralsk'] }, { country: 'KZ', priority: 'P4', contextRequiredAliases: ['Aral', 'Арал'] }),
|
|
164
172
|
catalogCity('Kazaly', { kk: ['Қазалы'], ru: ['Казалинск', 'Казалы'], en: ['Kazaly', 'Kazalinsk'] }, { country: 'KZ', priority: 'P4' }),
|
|
165
|
-
|
|
173
|
+
// "Shu" is a two-letter token with no unambiguous longer alias.
|
|
174
|
+
catalogCity('Shu', { kk: ['Шу'], ru: ['Шу'], en: ['Shu'] }, { country: 'KZ', priority: 'P4', contextRequired: true }),
|
|
166
175
|
catalogCity('Karatau', { kk: ['Қаратау'], ru: ['Каратау'], en: ['Karatau'] }, { country: 'KZ', priority: 'P4' }),
|
|
167
176
|
catalogCity('Zhanatas', { kk: ['Жаңатас'], ru: ['Жанатас'], en: ['Zhanatas'] }, { country: 'KZ', priority: 'P4' }),
|
|
168
177
|
catalogCity('Merke', { kk: ['Мерке'], ru: ['Мерке'], en: ['Merke'] }, { country: 'KZ', priority: 'P4' }),
|
|
@@ -66,8 +66,18 @@ export function detectCountryCodeFromText(value) {
|
|
|
66
66
|
const exact = canonicalCountryCode(text);
|
|
67
67
|
if (exact) return exact;
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Multiple countries can legitimately be mentioned in one text (e.g. a
|
|
70
|
+
// relocation ad, "from X to Y"). Prefer whichever is mentioned first
|
|
71
|
+
// rather than whichever happens to be declared first in COUNTRY_MATCHERS.
|
|
72
|
+
let earliestCountry = null;
|
|
73
|
+
let earliestStart = Infinity;
|
|
74
|
+
for (const { item, re } of COUNTRY_MATCHERS) {
|
|
75
|
+
const match = text.match(re);
|
|
76
|
+
if (!match) continue;
|
|
77
|
+
const start = match.index ?? 0;
|
|
78
|
+
if (start < earliestStart) { earliestStart = start; earliestCountry = item; }
|
|
79
|
+
}
|
|
80
|
+
if (earliestCountry?.code) return earliestCountry.code;
|
|
71
81
|
|
|
72
82
|
// Keep dotted U.S. and explicit "remote US" support without treating the
|
|
73
83
|
// ordinary English pronoun "us" as a geography signal.
|
|
@@ -81,10 +91,21 @@ export function detectCityFromText(value, country = null) {
|
|
|
81
91
|
const text = String(value || '');
|
|
82
92
|
if (!text) return null;
|
|
83
93
|
const code = country ? canonicalCountryCode(country) : null;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
// Prefer whichever known city is mentioned first in the text, not whichever
|
|
95
|
+
// is declared first in CITY_MATCHERS (see detectCitiesFromText, which
|
|
96
|
+
// already orders by match position — this mirrors that for the single-hit
|
|
97
|
+
// case).
|
|
98
|
+
let earliest = null;
|
|
99
|
+
let earliestStart = Infinity;
|
|
100
|
+
for (const matcher of CITY_MATCHERS) {
|
|
101
|
+
if (code && matcher.item.country !== code) continue;
|
|
102
|
+
const match = cityTextMatch(text, matcher);
|
|
103
|
+
if (!match) continue;
|
|
104
|
+
const start = match.index ?? 0;
|
|
105
|
+
if (start < earliestStart) { earliestStart = start; earliest = matcher.item; }
|
|
106
|
+
}
|
|
107
|
+
if (!earliest) return null;
|
|
108
|
+
return Object.freeze({ canonical: earliest.canonical, country: earliest.country || null });
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/** Detect every known city in free text, ordered by first mention and deduplicated by canonical name. */
|
package/src/geography.js
CHANGED
|
@@ -67,7 +67,9 @@ export const KG_CITIES = Object.freeze([
|
|
|
67
67
|
entity('Batken', { ky: ['Баткен'], ru: ['Баткен'], en: ['Batken'] }, { country: 'KG', type: 'city' }),
|
|
68
68
|
entity('Kara-Balta', { ky: ['Кара-Балта', 'Кара Балта'], ru: ['Кара-Балта', 'Кара Балта'], en: ['Kara-Balta', 'Kara Balta'] }, { country: 'KG', type: 'city' }),
|
|
69
69
|
entity('Balykchy', { ky: ['Балыкчы'], ru: ['Балыкчи'], en: ['Balykchy', 'Balykchi'] }, { country: 'KG', type: 'city' }),
|
|
70
|
-
|
|
70
|
+
// "Kant" collides with the philosopher's name in ordinary English prose
|
|
71
|
+
// and has no unambiguous longer alias, unlike Manas/Jalal-Abad above.
|
|
72
|
+
entity('Kant', { ky: ['Кант'], ru: ['Кант'], en: ['Kant'] }, { country: 'KG', type: 'city', contextRequired: true }),
|
|
71
73
|
entity('Uzgen', { ky: ['Өзгөн', 'Озгон'], ru: ['Узген', 'Озгон'], en: ['Uzgen', 'Özgön', 'Ozgon'] }, { country: 'KG', type: 'city' }),
|
|
72
74
|
entity('Kyzyl-Kiya', { ky: ['Кызыл-Кыя', 'Кызыл Кыя'], ru: ['Кызыл-Кия', 'Кызыл Кия'], en: ['Kyzyl-Kiya', 'Kyzyl Kiya'] }, { country: 'KG', type: 'city' }),
|
|
73
75
|
entity('Aydarken', { ky: ['Айдаркен'], ru: ['Айдаркен', 'Хайдаркан'], en: ['Aydarken', 'Aidarken', 'Khaidarkan'] }, { country: 'KG', type: 'city' }),
|
package/src/hiring-ats.js
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
import { detectDegreeRequirement, detectHiringScopeSignals } from './hiring-semantics.js';
|
|
8
8
|
import { canonicalSkillName, extractSkillNames, matchSkillCandidates } from './hiring-skills.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// "other" is unclassified/preamble text (no recognized section heading yet).
|
|
11
|
+
// It must rank below every genuine section — otherwise keyword-stuffing
|
|
12
|
+
// outside any real heading would outscore the same term listed under an
|
|
13
|
+
// explicit Skills section, rewarding exactly the wrong signal.
|
|
14
|
+
const SECTION_WEIGHT = Object.freeze({ experience: 1, projects: 0.7, profile: 0.55, skills: 0.4, education: 0.35, other: 0.3 });
|
|
11
15
|
const DEGREE_RANK = Object.freeze({ secondary: 0, bachelor: 1, master: 2, doctorate: 3 });
|
|
12
16
|
const SCOPE_LABELS = Object.freeze({ architecture: 'Architecture / system design', leadership: 'Technical leadership', mentoring: 'Mentoring engineers', scale: 'Large-scale systems', ownership: 'Product / feature ownership' });
|
|
13
17
|
const TERM_STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'your', 'you', 'our', 'are', 'will', 'have', 'has', 'who', 'what', 'when', 'where', 'which', 'their', 'they', 'them', 'about', 'within', 'across', 'using', 'including', 'work', 'working', 'team', 'teams', 'role', 'company', 'years', 'year', 'experience', 'skills', 'skill', 'strong', 'good', 'excellent', 'ability', 'knowledge', 'looking', 'required', 'requirements', 'preferred', 'responsibilities', 'opportunity', 'candidate', 'position', 'professional', 'develop', 'development', 'build', 'building', 'software', 'engineer', 'engineering', 'help', 'support', 'ensure', 'provide', 'plus', 'nice', 'must', 'need', 'needs', 'для', 'что', 'как', 'или', 'это', 'мы', 'вы', 'ваш', 'ваша', 'ваши', 'наш', 'наша', 'наши', 'работа', 'работы', 'работать', 'опыт', 'лет', 'года', 'год', 'команда', 'команды', 'знание', 'знания', 'навыки', 'требования', 'обязанности', 'будет', 'нужно', 'необходимо', 'умение', 'разработка', 'разработки', 'позиция', 'кандидат']);
|
package/src/hiring-context.js
CHANGED
|
@@ -91,13 +91,28 @@ export const LOCATION_CONTEXT_TERMS = Object.freeze([
|
|
|
91
91
|
]);
|
|
92
92
|
|
|
93
93
|
export const WORK_AUTHORIZATION_TERMS = Object.freeze([
|
|
94
|
-
group('sponsorshipOffered', { ru: ['визовая поддержка', 'спонсируем рабочую визу', 'оформляем рабочую визу'], en: ['visa sponsorship available', 'visa sponsorship provided', 'we sponsor visas', 'sponsorship available'], uk: ['візова підтримка', 'спонсоруємо робочу візу'], ro: ['sponsorizare viză', 'sponsorizare pentru viză'], uzLatn: ['viza yordami'], uzCyrl: ['виза ёрдами'], kk: ['визаға демеушілік'] }),
|
|
94
|
+
group('sponsorshipOffered', { ru: ['визовая поддержка', 'спонсируем рабочую визу', 'оформляем рабочую визу', 'виза h-1b', 'спонсорство h-1b', 'спонсорство визы h1b'], en: ['visa sponsorship available', 'visa sponsorship provided', 'we sponsor visas', 'sponsorship available', 'h-1b sponsorship', 'h1b sponsorship'], uk: ['візова підтримка', 'спонсоруємо робочу візу'], ro: ['sponsorizare viză', 'sponsorizare pentru viză'], uzLatn: ['viza yordami'], uzCyrl: ['виза ёрдами'], kk: ['визаға демеушілік'] }),
|
|
95
95
|
group('noSponsorship', { ru: ['без визовой поддержки', 'визу не спонсируем', 'спонсорства визы нет'], en: ['no visa sponsorship', 'visa sponsorship is not available', 'we do not sponsor', 'unable to sponsor', 'cannot sponsor', 'no sponsorship'], uk: ['без візової підтримки', 'візу не спонсоруємо'], ro: ['fără sponsorizare pentru viză'], uzLatn: ['viza homiyligi yoq'], uzCyrl: ['виза ҳомийлиги йўқ'], kk: ['виза демеушілігі жоқ'] }),
|
|
96
|
-
|
|
96
|
+
// Bare "патент" is deliberately excluded: it also means an IP patent
|
|
97
|
+
// ("патентное право", "работа с патентами"), so only phrases that
|
|
98
|
+
// unambiguously mean the RF migrant work-permit document qualify.
|
|
99
|
+
group('workPermitRequired', { ru: ['разрешение на работу обязательно', 'нужно разрешение на работу', 'патент на работу', 'нужен патент', 'требуется патент', 'наличие патента', 'патент обязателен'], en: ['work permit required', 'must have work authorization', 'must be authorized to work', 'right to work required'], uk: ['дозвіл на роботу обов’язковий'], ro: ['permis de muncă obligatoriu'], uzLatn: ['ishlash ruxsati kerak'], uzCyrl: ['ишлаш рухсати керак'], kk: ['жұмыс істеуге рұқсат қажет'] }),
|
|
97
100
|
group('citizenshipRequired', { ru: ['только граждане', 'гражданство обязательно'], en: ['citizenship required', 'citizens only'], uk: ['лише громадяни', 'громадянство обов’язкове'], ro: ['cetățenie obligatorie'], uzLatn: ['faqat fuqarolar'], uzCyrl: ['фақат фуқаролар'], kk: ['тек азаматтар'] }),
|
|
98
101
|
group('residencePermit', { ru: ['внж', 'вид на жительство'], en: ['residence permit', 'residency permit'], uk: ['посвідка на проживання'], ro: ['permis de ședere'], uzLatn: ['yashash ruxsati'], uzCyrl: ['яшаш рухсати'], kk: ['тұруға ықтиярхат'] }),
|
|
99
102
|
]);
|
|
100
103
|
|
|
104
|
+
// workAuthorization can carry several matched canonicals from the same text
|
|
105
|
+
// (e.g. a posting that both restricts and offers). When a negative/restrictive
|
|
106
|
+
// signal and a positive sponsorship signal co-occur, the restriction is the
|
|
107
|
+
// more specific, deliberately-stated one — drop the contradictory positive.
|
|
108
|
+
const CONTRADICTS_SPONSORSHIP_OFFERED = Object.freeze(['noSponsorship', 'citizenshipRequired']);
|
|
109
|
+
|
|
110
|
+
function resolveWorkAuthorizationConflicts(canonicals) {
|
|
111
|
+
if (!canonicals.includes('sponsorshipOffered')) return canonicals;
|
|
112
|
+
if (!canonicals.some((item) => CONTRADICTS_SPONSORSHIP_OFFERED.includes(item))) return canonicals;
|
|
113
|
+
return canonicals.filter((item) => item !== 'sponsorshipOffered');
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export const HIRING_AVAILABILITY_TERMS = Object.freeze([
|
|
102
117
|
group('urgent', { ru: ['срочно нужен', 'срочно требуется', 'срочный набор'], en: ['urgent hire', 'hiring urgently', 'urgent opening'], uk: ['терміново потрібен'], ro: ['angajare urgentă'], uzLatn: ['zudlik bilan xodim kerak'], uzCyrl: ['зудлик билан ходим керак'], kk: ['шұғыл қызметкер керек'] }),
|
|
103
118
|
group('immediateStart', { ru: ['выход завтра', 'приступить сразу', 'выход сразу'], en: ['immediate start', 'start immediately', 'asap start'], uk: ['вийти одразу', 'почати одразу'], ro: ['începere imediată'], uzLatn: ['darhol ish boshlash'], uzCyrl: ['дарҳол иш бошлаш'], kk: ['бірден бастау'] }),
|
|
@@ -400,7 +415,7 @@ export function parseHiringContext(value, { title = '', mode = null } = {}) {
|
|
|
400
415
|
application: matchCanonicals(text, APPLICATION_TERMS),
|
|
401
416
|
companyContext: matchCanonicals(text, COMPANY_TERMS),
|
|
402
417
|
locationContext: matchCanonicals(text, LOCATION_CONTEXT_TERMS),
|
|
403
|
-
workAuthorization: matchCanonicals(text, WORK_AUTHORIZATION_TERMS),
|
|
418
|
+
workAuthorization: resolveWorkAuthorizationConflicts(matchCanonicals(text, WORK_AUTHORIZATION_TERMS)),
|
|
404
419
|
availability: matchCanonicals(text, HIRING_AVAILABILITY_TERMS),
|
|
405
420
|
travel: has(text, TRAVEL_TERMS),
|
|
406
421
|
relocation: has(text, RELOCATION_CONTEXT_TERMS),
|
|
@@ -9,6 +9,9 @@ export function detectDegreeFields(value: unknown): readonly DegreeField[];
|
|
|
9
9
|
export function extractRequiredExperienceYears(value: unknown): number | null;
|
|
10
10
|
export function hasUsWorkAuthorization(value: unknown): boolean;
|
|
11
11
|
export function requiresUsSponsorship(value: unknown): boolean | null;
|
|
12
|
+
// Shared with hiring-source-semantics.js's detectVisaSponsorshipWording.
|
|
13
|
+
export const SPONSORSHIP_NOT_OFFERED_RE: RegExp;
|
|
14
|
+
export const SPONSORSHIP_OFFERED_RE: RegExp;
|
|
12
15
|
export function isNoSponsorshipRequirement(value: unknown): boolean;
|
|
13
16
|
export function bucketVacancyText(value: unknown): Readonly<{ required: string; optional: string; context: string; noise: string }>;
|
|
14
17
|
export function classifyCvSectionHeading(value: unknown): CvSection | null;
|
|
@@ -70,9 +70,37 @@ export function requiresUsSponsorship(value) {
|
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Shared with hiring-source-semantics.js's detectVisaSponsorshipWording so the
|
|
74
|
+
// negative and positive sponsorship signals cannot drift into two competing
|
|
75
|
+
// implementations. SPONSORSHIP_OBJECT covers "visa support" as an alternative
|
|
76
|
+
// object to "sponsorship" (not just the latter) so "We do not offer work visa
|
|
77
|
+
// support" / "no work visa sponsorship provided" resolve the same way as
|
|
78
|
+
// "we do not offer visa sponsorship" — the modifier group tries "work visa "
|
|
79
|
+
// as a unit first and falls back (via normal regex backtracking) to "work "
|
|
80
|
+
// alone so "visa support" is still available as the object.
|
|
81
|
+
const SPONSORSHIP_OBJECT_MODIFIER = '(?:work\\s+visa\\s+|work\\s+|visa\\s+|immigration\\s+|employment\\s+)?';
|
|
82
|
+
const SPONSORSHIP_OBJECT = '(?:sponsorship|visa\\s+support)';
|
|
83
|
+
|
|
84
|
+
export const SPONSORSHIP_NOT_OFFERED_RE = new RegExp(
|
|
85
|
+
`(?:\\bno\\s+${SPONSORSHIP_OBJECT_MODIFIER}${SPONSORSHIP_OBJECT}\\b`
|
|
86
|
+
+ `|\\b(?:will\\s+not|cannot|can't|unable\\s+to|not\\s+able\\s+to)\\s+sponsor\\b`
|
|
87
|
+
+ `|\\b(?:does|do)\\s+not\\s+(?:offer|provide|support)\\s+(?:current\\s+or\\s+future\\s+)?${SPONSORSHIP_OBJECT_MODIFIER}${SPONSORSHIP_OBJECT}\\b`
|
|
88
|
+
+ `|\\bwithout\\s+(?:the\\s+need\\s+for\\s+)?(?:(?:current\\s+(?:and\\/or|or)\\s+future|current|future)\\s+)?(?:employer\\s+|visa\\s+)?sponsorship\\b`
|
|
89
|
+
+ `|\\bmust\\s+(?:be\\s+)?(?:legally\\s+)?authoriz\\w+\\s+to\\s+work[^.!?]{0,100}\\bwithout\\s+(?:current\\s+or\\s+future\\s+)?sponsorship\\b`
|
|
90
|
+
+ `|\\bmust\\s+not\\s+require\\s+(?:current\\s+or\\s+future\\s+)?(?:visa\\s+|employment\\s+)?sponsorship\\b`
|
|
91
|
+
+ `|\\b(?:current\\s+and\\/or\\s+future|current\\s+or\\s+future)\\s+sponsorship\\s+(?:is\\s+)?not\\s+(?:available|provided|offered)\\b`
|
|
92
|
+
+ `|\\bsponsorship\\s+(?:is\\s+)?not\\s+(?:available|provided|offered)\\b`
|
|
93
|
+
+ `|\\bno\\s+c2c(?:\\s+or\\s+visa\\s+sponsorship)?\\b`
|
|
94
|
+
+ `|\\bmay\\s+not\\s+be\\s+able\\s+to\\b[^\\n!?]{0,450}\\b(?:sponsor|support|provide)\\b[^\\n!?]{0,180}\\bsponsorship\\b`
|
|
95
|
+
+ `|\\b(?:will|can|may)\\s+not\\b[^\\n!?]{0,220}\\b(?:sponsor|support|provide)\\b[^\\n!?]{0,160}\\bsponsorship\\b`
|
|
96
|
+
+ `|\\bnot\\s+(?:currently\\s+)?(?:able\\s+to\\s+)?(?:sponsor|support|provide)\\b[^\\n!?]{0,160}\\bsponsorship\\b)`,
|
|
97
|
+
'iu',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const SPONSORSHIP_OFFERED_RE = /(?:\bwill\s+sponsor\b|\bwe\s+sponsor\b|\b(?:can|may)\s+sponsor\b|\bopen\s+to\s+(?:visa\s+)?sponsorship\b|\bvisa\s+sponsorship\s+(?:is\s+)?(?:available|provided|offered|possible)\b|\b(?:h-?1b|h1-b)\s+(?:visa\s+)?sponsorship\b|\bh-?1b\s+transfer\b|\bimmigration\s+sponsorship\b|\bemployment\s+visa\s+sponsorship\b|\bwork\s+visa\s+sponsorship\b|\bsponsor(?:ing)?\s+(?:qualified|eligible|selected)\s+candidates\b|\beligible\s+for\s+(?:visa\s+)?sponsorship\b|\bvisa\s+support\b|\bwork\s+visa\s+support\b)/iu;
|
|
101
|
+
|
|
73
102
|
export function isNoSponsorshipRequirement(value) {
|
|
74
|
-
|
|
75
|
-
return /(?:\bno\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\b(?:will\s+not|cannot|can't|unable\s+to|not\s+able\s+to)\s+sponsor\b|\b(?:does|do)\s+not\s+(?:offer|provide|support)\s+(?:current\s+or\s+future\s+)?(?:visa\s+|employment\s+)?sponsorship\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:current\s+or\s+future\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,100}\bwithout\s+(?:current\s+or\s+future\s+)?sponsorship\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bmay\s+not\s+be\s+able\s+to\b[^\n!?]{0,450}\b(?:sponsor|support|provide)\b[^\n!?]{0,180}\bsponsorship\b|\b(?:will|can|may)\s+not\b[^\n!?]{0,220}\b(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/i.test(text);
|
|
103
|
+
return SPONSORSHIP_NOT_OFFERED_RE.test(String(value || ''));
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
const REQUIRED_MARKER_RE = /\b(requirements?|qualifications?|minimum qualifications?|required skills?|must[- ]?have|you have|what (?:we|you) (?:are looking for|need|bring)|you(?:'|’)ll need|who you are|ideal candidate|what makes you a fit)\b|требован|квалификац|обязательн|необходим(?:о|ые|ый)|что мы (?:жд[её]м|ожидаем)|кого мы ищем|вимог|кваліфікац|обов['’]?язков|необхідн|кого ми шукаємо/i;
|
package/src/hiring-skills.js
CHANGED
|
@@ -7,7 +7,7 @@ export { escapeRegex } from './normalization.js'
|
|
|
7
7
|
|
|
8
8
|
// These canonical labels are ordinary words or one-letter tokens. Matching the
|
|
9
9
|
// label itself would create noisy results; only their explicit aliases are safe.
|
|
10
|
-
const AMBIGUOUS_CANONICALS = new Set(['C', 'Go', 'Make', 'REST', 'Spring'])
|
|
10
|
+
const AMBIGUOUS_CANONICALS = new Set(['C', 'Go', 'Make', 'REST', 'Spring', 'R'])
|
|
11
11
|
|
|
12
12
|
const group = (category, subcategory, entries) =>
|
|
13
13
|
entries.map(([name, aliases = []]) => ({
|
|
@@ -22,7 +22,7 @@ const group = (category, subcategory, entries) =>
|
|
|
22
22
|
export const SKILL_CATALOG = [
|
|
23
23
|
...group('IT', 'Frontend', [
|
|
24
24
|
['HTML', ['html5']], ['CSS', ['css3']], ['Sass', ['scss']], ['Less', ['less css']],
|
|
25
|
-
['JavaScript', ['ecmascript', 'es6', 'js developer', 'js framework']], ['TypeScript', ['type script']],
|
|
25
|
+
['JavaScript', ['ecmascript', 'es6', 'js developer', 'js framework', 'js']], ['TypeScript', ['type script', 'ts']],
|
|
26
26
|
['React', ['react.js', 'reactjs']], ['React Native', ['react-native']],
|
|
27
27
|
['Vue', ['vue.js', 'vuejs']], ['Nuxt', ['nuxt.js', 'nuxtjs']],
|
|
28
28
|
['Next.js', ['nextjs', 'next js']], ['Angular', ['angular.js', 'angularjs']],
|
|
@@ -95,6 +95,7 @@ export const SKILL_CATALOG = [
|
|
|
95
95
|
]),
|
|
96
96
|
...group('Data', 'Analytics & AI', [
|
|
97
97
|
['Data Analysis', ['analytics', 'data analytics', 'анализ данных']], ['Business Analytics'], ['Commercial Analytics'],
|
|
98
|
+
['R', ['r language', 'r programming', 'rstudio', 'r stats', 'tidyverse']],
|
|
98
99
|
['Pandas'], ['NumPy'], ['Jupyter'], ['Power BI', ['powerbi']], ['Tableau'], ['Looker'], ['Qlik'],
|
|
99
100
|
['Apache Spark', ['pyspark']], ['Hadoop'], ['Airflow'], ['Kafka'], ['RabbitMQ'], ['ETL'],
|
|
100
101
|
['Data Warehouse'], ['Data Science'], ['Machine Learning', ['машинное обучение']], ['Deep Learning'], ['TensorFlow'],
|
|
@@ -3,6 +3,7 @@ import { aliasesOf, escapeRegex, normalizeUnicode } from './normalization.js';
|
|
|
3
3
|
import { parseSalary } from './money.js';
|
|
4
4
|
import { extractCandidateName } from './hiring-candidate-fields.js';
|
|
5
5
|
import { countryCurrency } from './country-context.js';
|
|
6
|
+
import { SPONSORSHIP_NOT_OFFERED_RE, SPONSORSHIP_OFFERED_RE } from './hiring-requirements.js';
|
|
6
7
|
|
|
7
8
|
const FIELD_EXTRA_ALIASES = Object.freeze({
|
|
8
9
|
candidate: Object.freeze({
|
|
@@ -248,13 +249,12 @@ export function detectUsLocation(value) {
|
|
|
248
249
|
return /\bunited states\b|\busa\b|\bu\.s\.?\b|\bUS(?:\s+remote)?\b|\b(?:AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC)\b/i.test(String(value || ''));
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// The negative/positive regexes themselves live in hiring-requirements.js so
|
|
253
|
+
// this detector and isNoSponsorshipRequirement() cannot drift apart.
|
|
254
254
|
export function detectVisaSponsorshipWording(value) {
|
|
255
255
|
const text = String(value || '');
|
|
256
|
-
if (
|
|
257
|
-
if (
|
|
256
|
+
if (SPONSORSHIP_NOT_OFFERED_RE.test(text)) return 'notOffered';
|
|
257
|
+
if (SPONSORSHIP_OFFERED_RE.test(text)) return 'offered';
|
|
258
258
|
return null;
|
|
259
259
|
}
|
|
260
260
|
|
package/src/housing-address.js
CHANGED
|
@@ -14,6 +14,11 @@ const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.
|
|
|
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}`;
|
|
16
16
|
const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
|
|
17
|
+
// Common post-Soviet street names lead with a bare numeral ("8 Марта",
|
|
18
|
+
// "50 лет Октября"). It's only ever a prefix before the required letter
|
|
19
|
+
// word(s) below, never a substitute for them, so it cannot swallow a
|
|
20
|
+
// following bare house number on its own.
|
|
21
|
+
const LEADING_STREET_NUMERAL = String.raw`\d{1,3}`;
|
|
17
22
|
const SECONDARY_TOKEN = String.raw`(?:${NUMBER_TOKEN}|[\p{L}])`;
|
|
18
23
|
const LEVEL_NUMBER_TOKEN = String.raw`\d{1,3}(?:[-–—]?(?:й|ый|ий|st|nd|rd|th))?`;
|
|
19
24
|
const LEVEL_MARKER = String.raw`(?:этаж(?:е|у|ом)?|поверх(?:у|е|ом)?|floor|qavat(?:da)?|қабат(?:та)?|кават|қават|etaj(?:da|ul)?)`;
|
|
@@ -24,8 +29,13 @@ const PROPERTY_AREA_LINE_RE = /(?:^|[^\p{L}\p{N}_])(?:(?:общая|жилая|
|
|
|
24
29
|
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
30
|
const DELIMITED_STREET_REJECT_RE = /(?:^|\s)(?:город|місто|city|район|р-н|рн|мікрорайон|микрорайон|мкр|жк|метро|поверх|этаж|floor|qavat|кімнат\p{L}*|комнат\p{L}*|квартира|квартири|квартиры|оренда|аренда|продаж\p{L}*|цена|ціна|площад\p{L}*|площа|зупинка|остановка|ориентир\p{L}*|ор[-–—]?р\.?)(?:\s|$)/iu;
|
|
26
31
|
const LOCATION_RELATION_RE = /(?:yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida|near(?:by)?|close\s+to|next\s+to|рядом|возле|около|недалеко|поруч|біля|lângă|aproape)/iu;
|
|
32
|
+
const DESCRIPTIVE_MAHALLA_WORD_RE = /^(?:orqasidagi|yonidagi|yaqinidagi|oldidagi|ortidagi|nearby|behind|opposite)$/iu;
|
|
33
|
+
// "кв." also abbreviates "квадратный" (square, as in "кв. м" / square meters).
|
|
34
|
+
// A lone captured letter must not be "м"/"m" itself, or "площадь 45 кв. м"
|
|
35
|
+
// would misread the area unit as an apartment number.
|
|
36
|
+
const UNIT_LETTER_TOKEN = String.raw`(?!(?:м|m)(?:²|2)?(?![\p{L}\p{N}]))[\p{L}]`;
|
|
27
37
|
const UNIT_COMPONENT_PATTERNS = Object.freeze([
|
|
28
|
-
String.raw`(?:^|[\s,;])(?:кв\.?|кв-ра)(?!\p{L})\s*(?:№|#)?\s*(${
|
|
38
|
+
String.raw`(?:^|[\s,;])(?:кв\.?|кв-ра)(?!\p{L})\s*(?:№|#)?\s*(${NUMBER_TOKEN}|${UNIT_LETTER_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
29
39
|
String.raw`(?:^|[\s,;])квартира\s*(?:№|#)\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
30
40
|
String.raw`(?:^|[\s,;])(?:apt\.?|ap\.?|unit)(?!\p{L})\s*(?:no\.?|nr\.?|№|#)?\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
31
41
|
String.raw`(?:^|[\s,;])apartament(?:ul)?\s*(?:nr\.?|№|#)\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
@@ -217,7 +227,11 @@ function tashkentGeoComponents(value) {
|
|
|
217
227
|
const district = matchTashkentHousingDistrict(text)?.name || null;
|
|
218
228
|
const metro = matchTashkentHousingMetro(text)?.name || null;
|
|
219
229
|
const mahalla = text.match(/(?:^|[^\p{L}])(\p{L}[\p{L}'’ʼ-]{1,48})\s+(?:mahalla(?:si)?|маҳалла(?:си)?|махалл[ая]|mfy)(?=$|[^\p{L}])/iu)?.[1] || null;
|
|
220
|
-
return Object.freeze({
|
|
230
|
+
return Object.freeze({
|
|
231
|
+
district,
|
|
232
|
+
metro,
|
|
233
|
+
mahalla: DESCRIPTIVE_MAHALLA_WORD_RE.test(mahalla || '') ? null : compactStreet(mahalla),
|
|
234
|
+
});
|
|
221
235
|
}
|
|
222
236
|
|
|
223
237
|
function attachGeoComponents(parsed, value) {
|
|
@@ -360,7 +374,7 @@ function splitAddressTail(raw) {
|
|
|
360
374
|
|
|
361
375
|
function postfixTypedStreetAddress(line) {
|
|
362
376
|
const suffix = line.match(new RegExp(
|
|
363
|
-
`(?:^|[^\\p{L}\\p{N}])((?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
|
|
377
|
+
`(?:^|[^\\p{L}\\p{N}])((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
|
|
364
378
|
`\\s*[,;]?\\s*(${NUMBER_TOKEN})` +
|
|
365
379
|
`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
|
|
366
380
|
`(?=$|[^\\p{L}\\p{N}])`,
|
|
@@ -384,7 +398,7 @@ function postfixTypedStreetAddress(line) {
|
|
|
384
398
|
function prefixTypedStreetAddress(line) {
|
|
385
399
|
const prefix = line.match(new RegExp(
|
|
386
400
|
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}\\s+` +
|
|
387
|
-
`((?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
|
|
401
|
+
`((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
|
|
388
402
|
`\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})` +
|
|
389
403
|
`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
|
|
390
404
|
`(?=$|[^\\p{L}\\p{N}])`,
|
|
@@ -443,7 +457,7 @@ function explicitStreetAddress(text) {
|
|
|
443
457
|
}
|
|
444
458
|
|
|
445
459
|
const boundedPrefix = line.match(new RegExp(
|
|
446
|
-
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}(?!\\p{L})\\s*((?:${STREET_WORD}\\s+){0,3}${STREET_WORD})(?=$|[,;])`,
|
|
460
|
+
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}(?!\\p{L})\\s*((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,3}${STREET_WORD})(?=$|[,;])`,
|
|
447
461
|
'iu',
|
|
448
462
|
));
|
|
449
463
|
if (boundedPrefix) {
|
|
@@ -12,6 +12,8 @@ export interface HousingListingEnrichment {
|
|
|
12
12
|
terrace?: boolean | null;
|
|
13
13
|
privateYard?: boolean | null;
|
|
14
14
|
dishwasher?: boolean | null;
|
|
15
|
+
refrigerator?: boolean | null;
|
|
16
|
+
washingMachine?: boolean | null;
|
|
15
17
|
airConditioner?: boolean | null;
|
|
16
18
|
tv?: boolean | null;
|
|
17
19
|
microwave?: boolean | null;
|
|
@@ -56,6 +58,7 @@ export interface HousingListingEnrichment {
|
|
|
56
58
|
district?: string | null;
|
|
57
59
|
quarter?: { number: number; suffix: string } | null;
|
|
58
60
|
metro?: string | null;
|
|
61
|
+
developmentArea?: string | null;
|
|
59
62
|
residenceComplex?: string | null;
|
|
60
63
|
address?: string | null;
|
|
61
64
|
addressStreet?: string | null;
|
|
@@ -12,6 +12,7 @@ import { HOUSING_LANDMARK_EXTENSIONS, HOUSING_POI_EXTENSIONS } from './housing-p
|
|
|
12
12
|
import { resolveHousingIntent } from './housing-intent.js';
|
|
13
13
|
import { extractHousingPoiRelations } from './housing-poi-relations.js';
|
|
14
14
|
import { dictionaryFor } from './locations-runtime.js';
|
|
15
|
+
import { matchCentralAsiaLocationEntities } from './central-asia-locations.js';
|
|
15
16
|
|
|
16
17
|
const GENERIC_CATEGORY = Object.freeze({
|
|
17
18
|
Park: 'park', Metro: 'metro', 'Bus stop': 'transport', 'Public transport': 'transport', 'Main road': 'transport',
|
|
@@ -32,11 +33,15 @@ const APPLIANCE_PATTERNS = Object.freeze([
|
|
|
32
33
|
const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
|
|
33
34
|
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;
|
|
34
35
|
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;
|
|
35
|
-
const NO_BROKER_RE = /(?:bez\s
|
|
36
|
+
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;
|
|
36
37
|
const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
|
|
37
38
|
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;
|
|
38
39
|
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;
|
|
39
40
|
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;
|
|
41
|
+
// A category word in an exclusion clause is negative evidence, not a tenant
|
|
42
|
+
// preference. Keep this narrow and local to avoid rejecting ordinary
|
|
43
|
+
// "oila uchun" / "для семьи" invitations elsewhere in the listing.
|
|
44
|
+
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;
|
|
40
45
|
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;
|
|
41
46
|
const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
|
|
42
47
|
const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
|
|
@@ -152,6 +157,13 @@ function cityMetro(text, country, city) {
|
|
|
152
157
|
return contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE)[0]?.canonical || null;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
function cityDevelopmentArea(text, country, city) {
|
|
161
|
+
const normalizedCountry = String(country || '').toUpperCase();
|
|
162
|
+
if (!['KZ', 'UZ'].includes(normalizedCountry) || !city) return null;
|
|
163
|
+
return matchCentralAsiaLocationEntities(text, normalizedCountry, city)
|
|
164
|
+
.matches.find((match) => match.type === 'development_area' && match.role !== 'nearby')?.name || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
155
167
|
function contextualNearby(text, country, city, metro) {
|
|
156
168
|
const shops = contextualCityMatches(text, country, city, 'landmarks', SUPERMARKET_PREFIX_RE)
|
|
157
169
|
.map((match) => match.canonical);
|
|
@@ -176,7 +188,7 @@ export function parseHousingNearby(value) {
|
|
|
176
188
|
|
|
177
189
|
export function parseHousingAudience(value) {
|
|
178
190
|
const text = normalizeUnicode(value ?? '');
|
|
179
|
-
const family = FAMILY_RE.test(text);
|
|
191
|
+
const family = FAMILY_RE.test(text) && !FAMILY_EXCLUSION_RE.test(text);
|
|
180
192
|
const women = WOMEN_RE.test(text);
|
|
181
193
|
const men = MEN_RE.test(text);
|
|
182
194
|
const students = STUDENT_RE.test(text);
|
|
@@ -284,10 +296,14 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
|
|
|
284
296
|
const areas = parseHousingAreas(text);
|
|
285
297
|
const audience = parseHousingAudience(text);
|
|
286
298
|
const perPersonPrice = parseHousingPerPersonPrice(text, { country });
|
|
287
|
-
const observedAmenities = parseHousingObservedAmenities(text)
|
|
299
|
+
const observedAmenities = parseHousingObservedAmenities(text).filter((amenity) =>
|
|
300
|
+
(amenity !== 'Washing machine' || listingFields.washingMachine !== false)
|
|
301
|
+
&& (amenity !== 'Refrigerator' || listingFields.refrigerator !== false),
|
|
302
|
+
);
|
|
288
303
|
const quarter = matchTashkentHousingQuarter(text);
|
|
289
304
|
const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
|
|
290
305
|
const metro = matchTashkentHousingMetro(text)?.name || cityMetro(text, country, city) || null;
|
|
306
|
+
const developmentArea = cityDevelopmentArea(text, country, city);
|
|
291
307
|
const primaryResidentialText = withoutNearbyLocationReferences(text);
|
|
292
308
|
const parsedRc = specificResidentialComplex(primaryResidentialText)
|
|
293
309
|
|| matchTashkentResidentialComplex(primaryResidentialText)?.name
|
|
@@ -318,6 +334,8 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
|
|
|
318
334
|
terrace: listingFields.terrace ?? null,
|
|
319
335
|
privateYard: listingFields.privateYard ?? null,
|
|
320
336
|
dishwasher: listingFields.dishwasher ?? null,
|
|
337
|
+
refrigerator: listingFields.refrigerator ?? null,
|
|
338
|
+
washingMachine: listingFields.washingMachine ?? null,
|
|
321
339
|
airConditioner: listingFields.airConditioner ?? (AIR_CONDITIONER_RE.test(text) ? true : null),
|
|
322
340
|
tv: listingFields.tv ?? null,
|
|
323
341
|
microwave: listingFields.microwave ?? null,
|
|
@@ -361,6 +379,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
|
|
|
361
379
|
district: district || null,
|
|
362
380
|
quarter: quarter ? { number: quarter.number, suffix: quarter.suffix } : null,
|
|
363
381
|
metro: metro || null,
|
|
382
|
+
developmentArea,
|
|
364
383
|
residenceComplex: parsedRc || null,
|
|
365
384
|
address: address.address,
|
|
366
385
|
addressStreet: address.street,
|
|
@@ -9,6 +9,28 @@ const bool = (text, positive, negative = null) => {
|
|
|
9
9
|
return positive.test(text) ? true : null;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const REFRIGERATOR_RE = /(?:холодильник\p{L}*|haladelnik|xolodilnik|muzlatgich|refrigerator|fridge)/iu;
|
|
13
|
+
const WASHING_MACHINE_RE = /(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|washing\s+machine|kir\s*moshina|kirmoshina|kir\s*yuvish\s+mashin\p{L}*)/iu;
|
|
14
|
+
|
|
15
|
+
function listedUzbekAbsence(text, appliance) {
|
|
16
|
+
// Marketplace Uzbek frequently puts a single "yo'q" after a short,
|
|
17
|
+
// separator-free appliance list: "haladelnik kir moshina gilam yo'q".
|
|
18
|
+
// Treat it as a list-level negative only inside the local clause and never
|
|
19
|
+
// across a positive "bor" assertion or a sentence boundary.
|
|
20
|
+
const clauses = String(text || '').matchAll(/([^.!?\r\n]{0,120})\b(?:yo['’ʻʼ`]?q|йўқ)(?=$|[^\p{L}\p{N}_])/giu);
|
|
21
|
+
for (const clause of clauses) {
|
|
22
|
+
const items = clause[1] || '';
|
|
23
|
+
if (/\bbor\b|мавжуд|есть|имеется|with\b/iu.test(items)) continue;
|
|
24
|
+
if (appliance.test(items)) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applianceState(text, appliance, directNegative) {
|
|
30
|
+
if (directNegative.test(text) || listedUzbekAbsence(text, appliance)) return false;
|
|
31
|
+
return appliance.test(text) ? true : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
const number = (match, min, max) => {
|
|
13
35
|
const value = match ? Number(match[1]) : null;
|
|
14
36
|
return Number.isFinite(value) && value >= min && value <= max ? value : null;
|
|
@@ -143,6 +165,16 @@ export function parseHousingListingFields(value, { country = '', dealType = null
|
|
|
143
165
|
const firstRent = bool(text,
|
|
144
166
|
/первая\s+(?:сдача|аренда)|впервые\s+(?:сда[её]тся|сдается|в\s+аренду)|(?:ранее|раньше|до\s+этого)\s+никто\s+не\s+жил|никто\s+(?:ранее|раньше)\s+не\s+жил|first\s+(?:rental|letting)|never\s+(?:rented|lived\s+in|occupied)/iu,
|
|
145
167
|
);
|
|
168
|
+
const refrigerator = applianceState(
|
|
169
|
+
text,
|
|
170
|
+
REFRIGERATOR_RE,
|
|
171
|
+
/(?:без|нет)\s+(?:холодильник\p{L}*|haladelnik|xolodilnik|muzlatgich)|(?:холодильник\p{L}*|haladelnik|xolodilnik|muzlatgich)\s+(?:нет|yo['’ʻʼ`]?q|йўқ)|no\s+(?:refrigerator|fridge)/iu,
|
|
172
|
+
);
|
|
173
|
+
const washingMachine = applianceState(
|
|
174
|
+
text,
|
|
175
|
+
WASHING_MACHINE_RE,
|
|
176
|
+
/(?:без|нет)\s+(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*moshina|kirmoshina)|(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*moshina|kirmoshina)\s+(?:нет|yo['’ʻʼ`]?q|йўқ)|no\s+washing\s+machine/iu,
|
|
177
|
+
);
|
|
146
178
|
|
|
147
179
|
return deepFreeze({
|
|
148
180
|
bedrooms: parseBedrooms(text),
|
|
@@ -166,6 +198,8 @@ export function parseHousingListingFields(value, { country = '', dealType = null
|
|
|
166
198
|
/посудомоечн\p{L}*|посудомойк\p{L}*|dishwasher|mașin[ăa]\s+de\s+spălat\s+vase/iu,
|
|
167
199
|
/без\s+посудомоечн\p{L}*(?:\s+машин\p{L}*)?|нет\s+посудомоечн\p{L}*(?:\s+машин\p{L}*)?|посудомоечн\p{L}*(?:\s+машин\p{L}*)?\s+нет|no\s+dishwasher/iu,
|
|
168
200
|
),
|
|
201
|
+
refrigerator,
|
|
202
|
+
washingMachine,
|
|
169
203
|
airConditioner: bool(
|
|
170
204
|
text,
|
|
171
205
|
/кондицион|сплит[- ]?систем|konditsioner|kansaner|kandisaner|klimat|air\s*con|aer\s+condi[țt]ionat/iu,
|
package/src/housing-money.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type HousingPriceParseResult = Readonly<{
|
|
|
2
2
|
amount: number | null;
|
|
3
3
|
currency: string;
|
|
4
4
|
approximate: boolean;
|
|
5
|
+
range?: Readonly<{ minimum: number; maximum: number }>;
|
|
5
6
|
}>;
|
|
6
7
|
|
|
7
8
|
export type HousingMoneyParseContext = Readonly<{
|
|
@@ -10,7 +11,7 @@ export type HousingMoneyParseContext = Readonly<{
|
|
|
10
11
|
fallbackCurrency?: string;
|
|
11
12
|
dealType?: 'sale' | 'longRent' | 'shortRent' | string | null;
|
|
12
13
|
}>;
|
|
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 }>;
|
|
14
|
+
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
15
|
export function extractHousingMoneyCandidates(value: unknown, context?: string | HousingMoneyParseContext): readonly HousingMoneyCandidate[];
|
|
15
16
|
export function rankHousingPriceCandidates(candidates: readonly HousingMoneyCandidate[]): readonly HousingMoneyCandidate[];
|
|
16
17
|
|
package/src/housing-money.js
CHANGED
|
@@ -199,7 +199,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
199
199
|
const text = maskPhoneLikeSpans(String(value || ''), ' ', { country });
|
|
200
200
|
const candidates = [];
|
|
201
201
|
const seen = new Set();
|
|
202
|
-
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 }) => {
|
|
203
203
|
if (amount == null || amount < 1 || amount > 5_000_000_000 || seen.has(`${start}:${end}`)) return;
|
|
204
204
|
seen.add(`${start}:${end}`);
|
|
205
205
|
candidates.push(Object.freeze({
|
|
@@ -210,6 +210,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
210
210
|
explicitCurrency,
|
|
211
211
|
scale,
|
|
212
212
|
priceKeyword,
|
|
213
|
+
range,
|
|
213
214
|
paymentRole: candidatePaymentRole(text, start, end),
|
|
214
215
|
approximate: APPROXIMATE_RE.test(text.slice(Math.max(0, start - 12), end)),
|
|
215
216
|
confidenceBoost,
|
|
@@ -223,7 +224,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
223
224
|
// than two competing prices. These must be extracted before the shorter
|
|
224
225
|
// generic currency/scale candidates below.
|
|
225
226
|
const expandedUzbekThousandsRe = new RegExp(
|
|
226
|
-
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})[.]000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
227
|
+
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
227
228
|
'igu',
|
|
228
229
|
);
|
|
229
230
|
for (const match of text.matchAll(expandedUzbekThousandsRe)) {
|
|
@@ -239,6 +240,38 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
239
240
|
});
|
|
240
241
|
}
|
|
241
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*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?(?=$|[^\\p{L}\\p{N}_])`,
|
|
251
|
+
'igu',
|
|
252
|
+
);
|
|
253
|
+
for (const match of text.matchAll(labelledPriceRangeRe)) {
|
|
254
|
+
// A scale stated on only one endpoint applies to both: "50-60 тыс сум"
|
|
255
|
+
// means 50,000-60,000, not 50-60,000. Mirrors money.js's range parsing.
|
|
256
|
+
const firstScale = match[2] || match[4] || null;
|
|
257
|
+
const secondScale = match[4] || match[2] || null;
|
|
258
|
+
const minimum = parsedMoneyAmount(match[1], firstScale);
|
|
259
|
+
const maximum = parsedMoneyAmount(match[3], secondScale);
|
|
260
|
+
if (minimum == null || maximum == null || minimum > maximum) continue;
|
|
261
|
+
const start = match.index ?? 0;
|
|
262
|
+
addCandidate({
|
|
263
|
+
amount: minimum,
|
|
264
|
+
currency: country === 'UZ' ? 'UZS' : fallbackCurrency || '',
|
|
265
|
+
start,
|
|
266
|
+
end: start + match[0].length,
|
|
267
|
+
explicitCurrency: false,
|
|
268
|
+
scale: firstScale || secondScale || null,
|
|
269
|
+
priceKeyword: true,
|
|
270
|
+
range: Object.freeze({ minimum, maximum }),
|
|
271
|
+
confidenceBoost: 0.2,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
242
275
|
const splitMillionRe = /(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/giu;
|
|
243
276
|
for (const match of text.matchAll(splitMillionRe)) {
|
|
244
277
|
const start = match.index ?? 0;
|
|
@@ -365,11 +398,13 @@ export function parseHousingPrice(value, context = '') {
|
|
|
365
398
|
// regional formats below remain as deterministic fallbacks until each is
|
|
366
399
|
// represented by a richer candidate extractor.
|
|
367
400
|
if (preferredCandidate && preferredCandidate.confidence >= 0.65) {
|
|
368
|
-
|
|
401
|
+
const result = {
|
|
369
402
|
amount: preferredCandidate.amount,
|
|
370
403
|
currency: preferredCandidate.currency || fallbackCurrency || '',
|
|
371
404
|
approximate: preferredCandidate.approximate,
|
|
372
|
-
}
|
|
405
|
+
};
|
|
406
|
+
if (preferredCandidate.range) result.range = preferredCandidate.range;
|
|
407
|
+
return Object.freeze(result);
|
|
373
408
|
}
|
|
374
409
|
let currency = moneyCurrencyFromText(priceText, fallbackCurrency || '')
|
|
375
410
|
|| moneyCurrencyFromText(text, fallbackCurrency || '')
|
|
@@ -408,7 +443,7 @@ export function parseHousingPrice(value, context = '') {
|
|
|
408
443
|
// Uzbek ads also use a dot as a thousands separator after four leading
|
|
409
444
|
// digits: "2500.000 сум" means 2,500,000 UZS, not 2,500 UZS.
|
|
410
445
|
const expandedUzbekThousands = priceText.match(new RegExp(
|
|
411
|
-
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})[.]000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
446
|
+
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
412
447
|
'iu',
|
|
413
448
|
));
|
|
414
449
|
if (price == null && expandedUzbekThousands) {
|
|
@@ -517,7 +552,16 @@ export function parseHousingPrice(value, context = '') {
|
|
|
517
552
|
const digits = raw.replace(/[\s.,]/g, '');
|
|
518
553
|
if (digits[0] === '0') continue;
|
|
519
554
|
const amount = parseNumericAmount(raw);
|
|
520
|
-
if (amount
|
|
555
|
+
if (amount == null || amount < 1000 || amount > 5_000_000_000) continue;
|
|
556
|
+
// A bare 4-digit amount in this range is at least as likely to be a
|
|
557
|
+
// build year ("2022 \u0433\u043E\u0434\u0430 \u043F\u043E\u0441\u0442\u0440\u043E\u0439\u043A\u0438") as a price. Only accept it here
|
|
558
|
+
// when there's independent currency/price evidence nearby.
|
|
559
|
+
if (amount >= 1900 && amount <= 2100) {
|
|
560
|
+
const window = priceText.slice(Math.max(0, start - 40), Math.min(priceText.length, start + raw.length + 40));
|
|
561
|
+
const hasPriceEvidence = PRICE_KEYWORD_RE.test(window) || Boolean(moneyCurrencyFromText(window, ''));
|
|
562
|
+
if (!hasPriceEvidence) continue;
|
|
563
|
+
}
|
|
564
|
+
if (best == null || amount > best) best = amount;
|
|
521
565
|
}
|
|
522
566
|
price = best;
|
|
523
567
|
}
|
|
@@ -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;
|
|
@@ -286,19 +286,27 @@ export function matchTashkentHousingMetro(value) {
|
|
|
286
286
|
const text = String(value ?? '');
|
|
287
287
|
if (!text) return null;
|
|
288
288
|
for (const station of TASHKENT_METRO) {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
if (
|
|
289
|
+
const flags = [...new Set(`${station.re.flags.replace(/g/gu, '')}g`)].join('');
|
|
290
|
+
const matches = [...text.matchAll(new RegExp(station.re.source, flags))];
|
|
291
|
+
if (!matches.length) continue;
|
|
292
|
+
// An explicit marker must win even if an earlier bare occurrence shares a
|
|
293
|
+
// name with a district. For example, "Sergeli tumani, metro Sergeli"
|
|
294
|
+
// still refers to the station at the second occurrence.
|
|
295
|
+
if (matches.some((match) => hasExplicitMetroContext(text, match))) return station;
|
|
296
|
+
const sameNamedDistrict = hasExplicitTashkentDistrict(text, station.name);
|
|
297
|
+
for (const match of matches) {
|
|
298
|
+
if (sameNamedDistrict) continue;
|
|
292
299
|
// "Toshkent" is both a metro station and the city's own name, so a bare
|
|
293
300
|
// mention ("Toshkent shahri") is not evidence of the station the way a
|
|
294
301
|
// bare mention of any other station name would be. Require an explicit
|
|
295
302
|
// metro context for this one station specifically.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
303
|
+
if (station.name === 'Toshkent') continue;
|
|
304
|
+
if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
|
|
305
|
+
const areaCanonical = METRO_NUMBERED_AREA[station.name];
|
|
306
|
+
if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
|
|
307
|
+
if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
|
|
308
|
+
return station;
|
|
309
|
+
}
|
|
302
310
|
}
|
|
303
311
|
for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
|
|
304
312
|
const match = text.match(aliasesToRegex(aliases));
|
package/src/temporal.js
CHANGED
|
@@ -23,13 +23,14 @@ const DAY_ALIASES = Object.freeze({
|
|
|
23
23
|
сб: 5, суббота: 5, субботы: 5, субота: 5, sat: 5, saturday: 5, shanba: 5, сенбі: 5, ишемби: 5, sâmbătă: 5, sambata: 5,
|
|
24
24
|
вс: 6, воскресенье: 6, воскресенья: 6, неділя: 6, неділю: 6, sun: 6, sunday: 6, yakshanba: 6, жексенбі: 6, жекшемби: 6, duminică: 6, duminica: 6,
|
|
25
25
|
});
|
|
26
|
-
const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(
|
|
26
|
+
const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(послезавтрашн\p{L}*\s+дн\p{L}*|сегодняшн\p{L}*\s+дн\p{L}*|завтрашн\p{L}*\s+дн\p{L}*|післязавтрашн\p{L}*\s+дн\p{L}*|сьогоднішн\p{L}*\s+дн\p{L}*|сегодня|завтра|послезавтра|сьогодні|післязавтра|bugun|ertaga|indin|бүгін|ертең|бүрсігүні|бүгүн|эртең|бүрсүгүнү|astăzi|azi|mâine|maine|poimâine|poimaine|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
|
|
27
27
|
const EXTENDED_RELATIVE_RE = /(?<![\p{L}\p{N}])(?:через\s+(\d+)\s+(дні|днів|тижд(?:ень|ні|нів|ня))|peste\s+(\d+)\s+(zile?|săptămân(?:ă|i)|saptaman(?:a|i))|(\d+)\s+(күннен|аптадан|кун(?:дөн|дон)|жумадан)\s+(?:кейін|кийин))(?![\p{L}\p{N}])/giu;
|
|
28
28
|
const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
|
|
29
29
|
const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
|
|
30
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
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;
|
|
32
|
+
const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап)\s+(?:начала\s+месяца|початку\s+місяця|începutul\s+lunii|start\s+of\s+(?:the\s+)?month|1(?:-го)?\s+числа|первого\s+числа|1(?:-го)?\s+числа\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
33
|
+
const NEXT_MONTH_RE = /(?<![\p{L}\p{N}])(?:next\s+month|со?\s+следующего\s+месяца|з\s+наступного\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
33
34
|
const SHIFT_RE = /(?<!\d)(\d{1,2})\s*(?:смен[аы]|shift)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:-|–|—|до|to)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/giu;
|
|
34
35
|
|
|
35
36
|
function referenceEntry(context = {}) {
|
|
@@ -44,8 +45,19 @@ function referenceDate(context = {}) { return referenceEntry(context).date; }
|
|
|
44
45
|
function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
|
|
45
46
|
function validDate(value) { const date = new Date(Date.UTC(value.year, value.month - 1, value.day)); return date.getUTCFullYear() === value.year && date.getUTCMonth() === value.month - 1 && date.getUTCDate() === value.day; }
|
|
46
47
|
function candidate(entityType, value, match, parser, confidence, evidence) { const start = match.index ?? 0; return createParseCandidate({ id: `${entityType}:${start}`, entityType, value, raw: match[0], start, end: start + match[0].length, parser, confidence, evidence }); }
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// Bare "с"/"з"/"до" are single Cyrillic letters and match as a substring of
|
|
49
|
+
// countless ordinary words ("Сдаю", "доступна") without a boundary guard —
|
|
50
|
+
// "Сдаю до 15.03" was misread as 'from' off the "С" in "Сдаю" alone, hiding
|
|
51
|
+
// the actual "до" (until) marker that followed.
|
|
52
|
+
const RELATION_FROM_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))(?![\p{L}\p{N}])/iu;
|
|
53
|
+
const RELATION_UNTIL_RE = /(?<![\p{L}\p{N}])(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)(?![\p{L}\p{N}])/iu;
|
|
54
|
+
function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return RELATION_FROM_RE.test(before) ? 'from' : RELATION_UNTIL_RE.test(before) ? 'until' : 'exact'; }
|
|
55
|
+
// A year-less date is assumed to refer to its next occurrence when the
|
|
56
|
+
// current year's reading has already passed — not just for explicit
|
|
57
|
+
// "from"/"until" wording. A bare mention with no relation ('exact') is the
|
|
58
|
+
// common case (e.g. "12 января встреча") and would otherwise silently
|
|
59
|
+
// resolve to a date up to a year in the past.
|
|
60
|
+
function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
|
|
49
61
|
function parseClock(raw) { const match = String(raw).trim().match(new RegExp(String.raw`^(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?$`, 'iu')); if (!match) return null; let hour = Number(match[1]); const minute = Number(match[2] || 0); const suffix = String(match[3] || '').toLowerCase(); if (suffix === 'pm' && hour < 12) hour += 12; if (suffix === 'am' && hour === 12) hour = 0; if (/(?:вечера|вечора|kechqurun)/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
|
|
50
62
|
function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year|yil|жыл|рок|\ban/i.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month|\boy|місяц|lun/i.test(normalized) ? 'month' : /нед|week|hafta|апта|тиж|săptăm|saptaman/i.test(normalized) ? 'week' : /дн|день|day|\bkun|күн|zi/i.test(normalized) ? 'day' : /мин|minute|daqiqa/i.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш|at\s+least/iu.test(prefix || '') ? 'min' : /до|не\s+более|ko'?pi\s+bilan|көп\s+емес/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
|
|
51
63
|
function semanticDurationType(text, start, duration) {
|
|
@@ -109,6 +121,7 @@ function isClockContextual(match, text) {
|
|
|
109
121
|
return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
|
|
110
122
|
}
|
|
111
123
|
function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
|
|
124
|
+
function dateAtNextMonthStart(reference) { const month = reference.getUTCMonth() + 2; const year = reference.getUTCFullYear() + Math.floor((month - 1) / 12); return dateValue(year, ((month - 1) % 12) + 1, 1); }
|
|
112
125
|
function nextWeekday(date, weekday) { const current = (date.getUTCDay() + 6) % 7; let days = (weekday - current + 7) % 7; if (days === 0) days = 7; return addUtcDays(date, days); }
|
|
113
126
|
|
|
114
127
|
/** Extract generic temporal candidates without silently fabricating a Date. */
|
|
@@ -120,7 +133,7 @@ export function extractTemporalCandidates(value, context = {}) {
|
|
|
120
133
|
}
|
|
121
134
|
for (const match of text.matchAll(new RegExp(DATE_NUMERIC_PARTIAL_RE, 'gu'))) {
|
|
122
135
|
const start = match.index ?? 0; const around = text.slice(Math.max(0, start - 40), start + match[0].length + 40);
|
|
123
|
-
if (!/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?|свобод|доступ|заезд|заезж|available|move[- ]?in
|
|
136
|
+
if (!/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?|свобод|доступ|заезд|заезж|available|move[- ]?in|(?<![\p{L}\p{N}])(?:с|до|from|until)(?![\p{L}\p{N}]))/iu.test(around)) continue;
|
|
124
137
|
const relation = relationNear(text, start); const inferred = true;
|
|
125
138
|
const date = dateValue(inferYear(Number(match[2]), Number(match[1]), relation, context), Number(match[2]), Number(match[1]));
|
|
126
139
|
if (validDate(date)) candidates.push(candidate(dateEntityType(text, start, context, relation), date, match, 'temporal.calendar.numeric-partial', .86, [{ type: 'regex', rule: 'numeric-partial-date' }, ...inferredDateEvidence(inferred, context)]));
|
|
@@ -144,6 +157,10 @@ export function extractTemporalCandidates(value, context = {}) {
|
|
|
144
157
|
const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
|
|
145
158
|
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.month-start', .88, [{ type: 'context', value: 'month-start' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
146
159
|
}
|
|
160
|
+
for (const match of text.matchAll(NEXT_MONTH_RE)) {
|
|
161
|
+
const date = dateAtNextMonthStart(referenceDate(context));
|
|
162
|
+
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.next-month', .88, [{ type: 'context', value: 'next-month' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
163
|
+
}
|
|
147
164
|
for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
|
|
148
165
|
const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
|
|
149
166
|
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), nextWeekday(referenceDate(context), weekday), match, 'temporal.relative.next-weekday', .9, [{ type: 'dictionary', dictionary: 'weekdays', key: match[1] }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
@@ -158,7 +175,15 @@ export function extractTemporalCandidates(value, context = {}) {
|
|
|
158
175
|
candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date.extended', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'unit', value: unit }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
159
176
|
}
|
|
160
177
|
for (const match of text.matchAll(new RegExp(DURATION_NUMBER_FIRST_RE, 'giu'))) { const value = durationValue(match[1], match[2], match[3]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
|
|
161
|
-
for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
|
|
178
|
+
for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
|
|
179
|
+
if (match[3]) continue;
|
|
180
|
+
// Bare genitive "дня" is almost always part of another phrase ("конца
|
|
181
|
+
// дня", "сегодняшнего дня", "через 2 дня") rather than a standalone
|
|
182
|
+
// 1-day duration. Only accept it here when an explicit duration prefix
|
|
183
|
+
// ("на", "минимум", ...) makes the duration reading unambiguous.
|
|
184
|
+
if (!match[1] && /^дня$/iu.test(match[2])) continue;
|
|
185
|
+
const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])]));
|
|
186
|
+
}
|
|
162
187
|
const timeRangeSpans = [];
|
|
163
188
|
for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
|
|
164
189
|
const start = parseClock(match[1]); const end = parseClock(match[2]);
|