@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- if (!/(?:shahri|город|viloyati|область|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
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 PHONE_CANDIDATE_RE = /\+?\d(?:[\t \u00a0().-]*\d){6,}(?:[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*\d{1,6})?/giu;
10
- const PHONE_EXTENSION_RE = /[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*(\d{1,6})$/iu;
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
- const TELEGRAM_USERNAME_RE = /^[A-Za-z0-9_]{5,32}$/;
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
- const keyword = text.match(/(?<![\p{L}\p{N}_])(?:tel|тел|phone|моб|whats?app|viber|telegram|звонит|звоніть|aloqa|byla|contact)(?![\p{L}\p{N}_])[^\d+]{0,8}(\+?\d[\d\s().-]{6,}\d)/iu);
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
- entity('Oral', { kk: ['Орал'], ru: ['Уральск', 'Орал'], en: ['Oral', 'Uralsk'] }, { country: 'KZ' }),
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
- catalogCity('Alga', { kk: ['Алға'], ru: ['Алга'], en: ['Alga'] }, { country: 'KZ', priority: 'P4' }),
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
- catalogCity('Aral', { kk: ['Арал'], ru: ['Аральск', 'Арал'], en: ['Aral', 'Aralsk'] }, { country: 'KZ', priority: 'P4' }),
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
- catalogCity('Shu', { kk: ['Шу'], ru: ['Шу'], en: ['Shu'] }, { country: 'KZ', priority: 'P4' }),
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
- const country = COUNTRY_MATCHERS.find(({ re }) => re.test(text))?.item;
70
- if (country?.code) return country.code;
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
- const match = CITY_MATCHERS.find((matcher) =>
85
- (!code || matcher.item.country === code) && Boolean(cityTextMatch(text, matcher)));
86
- if (!match) return null;
87
- return Object.freeze({ canonical: match.item.canonical, country: match.item.country || null });
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
- entity('Kant', { ky: ['Кант'], ru: ['Кант'], en: ['Kant'] }, { country: 'KG', type: 'city' }),
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
- const SECTION_WEIGHT = Object.freeze({ experience: 1, projects: 0.7, profile: 0.55, skills: 0.4, education: 0.35, other: 0.45 });
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', 'для', 'что', 'как', 'или', 'это', 'мы', 'вы', 'ваш', 'ваша', 'ваши', 'наш', 'наша', 'наши', 'работа', 'работы', 'работать', 'опыт', 'лет', 'года', 'год', 'команда', 'команды', 'знание', 'знания', 'навыки', 'требования', 'обязанности', 'будет', 'нужно', 'необходимо', 'умение', 'разработка', 'разработки', 'позиция', 'кандидат']);
@@ -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
- 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: ['жұмыс істеуге рұқсат қажет'] }),
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
- const text = String(value || '');
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;
@@ -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
- const NEGATIVE_SPONSORSHIP_RE = /(?:\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|\bdo(?:es)?\s+not\s+(?:offer|provide)\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:(?:current\s+(?:and\/or|or)\s+future|current|future)\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,80}\bwithout\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?sponsorship\b|\bmust\s+not\s+require\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?(?:visa\s+|employment\s+)?sponsorship\b|\b(?:current\s+and\/or\s+future|current\s+or\s+future)\s+sponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bno\s+c2c(?:\s+or\s+visa\s+sponsorship)?\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(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/iu;
252
- const POSITIVE_SPONSORSHIP_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;
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 (NEGATIVE_SPONSORSHIP_RE.test(text)) return 'notOffered';
257
- if (POSITIVE_SPONSORSHIP_RE.test(text)) return 'offered';
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
 
@@ -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*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
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({ district, metro, mahalla: compactStreet(mahalla) });
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+makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси)|no\s+(?:broker|agent|commission|agency\s+fee)|f[ăa]r[ăa]\s+(?:comision|agen[țt]ie|intermediari)|делдалсыз|комиссиясыз)/iu;
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,
@@ -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
 
@@ -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
- return Object.freeze({
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 != null && amount >= 1000 && amount <= 5_000_000_000 && (best == null || amount > best)) best = 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*)?хона(?:лик|ли)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
90
+ const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:ta\s*)?(?:(?:-\s*)?комнат\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li|si|lari)?|(?:-\s*)?хона(?:лик|ли|си|лари)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
91
91
  if (numeric) {
92
92
  const rooms = toNumber(numeric[1]);
93
93
  if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
@@ -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 match = text.match(station.re);
290
- if (!match) continue;
291
- if (hasExplicitMetroContext(text, match)) return station;
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
- if (station.name === 'Toshkent') continue;
297
- if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
298
- const areaCanonical = METRO_NUMBERED_AREA[station.name];
299
- if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
300
- if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
301
- return station;
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));
@@ -4,6 +4,7 @@ const AMBIGUOUS_TASHKENT_COMPLEXES = new Set([
4
4
  'Amir Temur',
5
5
  'Bodomzor',
6
6
  'City Mall',
7
+ 'DARVOZA',
7
8
  'Darhan',
8
9
  'Pushkin',
9
10
  'Seoul Mun',
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+)?(сегодня|завтра|послезавтра|сьогодні|післязавтра|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;
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
- 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'; }
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; }
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|\b(?:с|до|from|until)\b)/iu.test(around)) continue;
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'))) { 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] }] : [])])); }
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]);