@whiteslove/parsing-lexicon 0.5.0 → 0.5.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.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/geo.js CHANGED
@@ -160,7 +160,7 @@ export const TASHKENT_AREAS = Object.freeze({
160
160
  ]),
161
161
  Yunusabad: Object.freeze([
162
162
  area('Kashgar', ['кашгар', 'қашқар', 'qashqar', 'kashgar', 'ц 4', 'c 4']), area('Qiyot', ['киёт', 'кият', 'қиёт', 'qiyot', 'kiyot', 'markaz 5', 's 5']),
163
- area('Minor', ['минор', 'minor', 'ц 6', 'c 6']), area('TashGRES', ['ташгрэс', 'tashgres']), area('Manzara', ['манзара', 'manzara']),
163
+ area('Minor', ['минор', 'minor', 'ц 6', 'c 6']), area('ToshGRES', ['ташгрэс', 'tashgres', 'TashGRES', 'toshgres']), area('Manzara', ['манзара', 'manzara']),
164
164
  ]),
165
165
  Yakkasaray: Object.freeze([
166
166
  area('Bashlyk', ['башлык', 'бошлиқ', 'boshliq', 'bashlyk']), area('Kushbegi', ['кушбеги', 'қушбеги', 'qushbegi', 'kushbegi']),
@@ -1,13 +1,39 @@
1
- export type GeographyDisplayKind = 'country' | 'region' | 'city' | 'district' | 'metro' | 'any';
1
+ export type GeographyDisplayKind =
2
+ | 'country'
3
+ | 'region'
4
+ | 'city'
5
+ | 'district'
6
+ | 'microdistrict'
7
+ | 'mahalla'
8
+ | 'local_area'
9
+ | 'suburb'
10
+ | 'settlement'
11
+ | 'development_area'
12
+ | 'residential_complex'
13
+ | 'metro'
14
+ | 'street'
15
+ | 'poi'
16
+ | 'any';
17
+
18
+ export type GeographyDisplayContext = Readonly<{
19
+ country?: string | null;
20
+ city?: string | null;
21
+ }>;
2
22
 
3
23
  export const GEOGRAPHY_DISPLAY_NAMES: Readonly<Record<string, Readonly<{
4
24
  country: Readonly<Record<string, string>>;
5
25
  city: Readonly<Record<string, string>>;
6
26
  region?: Readonly<Record<string, string>>;
7
27
  district: Readonly<Record<string, string>>;
28
+ microdistrict: Readonly<Record<string, string>>;
8
29
  metro: Readonly<Record<string, string>>;
9
30
  metroAlias: Readonly<Record<string, string>>;
10
31
  }>>>;
11
32
 
12
- export function geographyDisplayName(value: unknown, locale?: string, kind?: GeographyDisplayKind): string;
33
+ export function geographyDisplayName(
34
+ value: unknown,
35
+ locale?: string,
36
+ kind?: GeographyDisplayKind,
37
+ context?: GeographyDisplayContext | null,
38
+ ): string;
13
39
  export function geographyMetroLabelWithAlias(value: unknown, locale?: string): string;
@@ -1,11 +1,34 @@
1
1
  import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
2
2
  import { CITIES, REGIONS } from './geography.js';
3
+ import { dictionaryFor } from './locations.js';
4
+ import { aliasesOf } from './normalization.js';
3
5
 
4
6
  // Centralized presentation names for canonical geography values.
5
7
  // Consumers must not maintain their own country/city/district/metro display dictionaries.
6
8
 
7
9
  const EMPTY_DISPLAY_NAMES = Object.freeze({});
8
10
 
11
+ const LOCATION_KIND_KEYS = Object.freeze({
12
+ district: Object.freeze(['districts']),
13
+ microdistrict: Object.freeze(['microdistricts']),
14
+ mahalla: Object.freeze(['mahallas']),
15
+ local_area: Object.freeze(['localAreas']),
16
+ suburb: Object.freeze(['suburbs']),
17
+ settlement: Object.freeze(['settlements']),
18
+ development_area: Object.freeze(['developmentAreas']),
19
+ residential_complex: Object.freeze(['residentialComplexes']),
20
+ metro: Object.freeze(['metro']),
21
+ street: Object.freeze(['streets']),
22
+ poi: Object.freeze(['landmarks', 'pois']),
23
+ });
24
+
25
+ const CYRILLIC_RE = /[А-ЯЁ]/iu;
26
+ const UZBEK_CYRILLIC_RE = /[ЎҚҒҲўқғҳ]/u;
27
+ const LATIN_RE = /[A-Z]/iu;
28
+ const UZBEK_LATIN_MARKER_RE = /(?:[ʻ‘’ʼ']|\b(?:ko[ʻ‘’ʼ']?chasi|shoh\s+ko[ʻ‘’ʼ']?chasi|bozori|bog[ʻ‘’ʼ']?i|maydoni|masjidi|vokzali|mahallasi|mavzesi|dahasi|massivi|savdo\s+markazi|toshkent|yunusobod|chilonzor|olmazor|o[ʻ‘’ʼ']?zbekiston)\b)/iu;
29
+ const ENGLISH_GENERIC_RE = /\b(?:street|avenue|square|park|bazaar|market|mall|mosque|railway|station|residence|city|garden|museum|theatre|theater|airport|university|hospital|school|district|microdistrict)\b/iu;
30
+ const RUSSIAN_GENERIC_RE = /\b(?:улица|проспект|площадь|сквер|рынок|базар|парк|мечеть|вокзал|аэропорт|университет|больница|школа|район|массив|махалл|жилой|жк|центр)\b/iu;
31
+
9
32
  function countryDisplayNames(locale) {
10
33
  return Object.freeze(Object.fromEntries(COUNTRIES.map((item) => [
11
34
  item.code,
@@ -22,6 +45,20 @@ export const GEOGRAPHY_DISPLAY_NAMES = Object.freeze({
22
45
  metro: EMPTY_DISPLAY_NAMES,
23
46
  metroAlias: EMPTY_DISPLAY_NAMES,
24
47
  }),
48
+ uz: Object.freeze({
49
+ country: countryDisplayNames('uz'),
50
+ city: Object.freeze({
51
+ Tashkent: 'Toshkent',
52
+ }),
53
+ district: Object.freeze({
54
+ Chilanzar: 'Chilonzor', Yunusabad: 'Yunusobod', 'Mirzo Ulugbek': 'Mirzo Ulug‘bek',
55
+ Yakkasaray: 'Yakkasaroy', Shaykhantahur: 'Shayxontohur', Yashnobod: 'Yashnobod', Sergeli: 'Sergeli',
56
+ Uchtepa: 'Uchtepa', Mirobod: 'Mirobod', Bektemir: 'Bektemir', Almazar: 'Olmazor', Yangihayot: 'Yangihayot',
57
+ }),
58
+ microdistrict: EMPTY_DISPLAY_NAMES,
59
+ metro: EMPTY_DISPLAY_NAMES,
60
+ metroAlias: EMPTY_DISPLAY_NAMES,
61
+ }),
25
62
  ru: Object.freeze({
26
63
  country: countryDisplayNames('ru'),
27
64
  city: Object.freeze({
@@ -69,6 +106,8 @@ export const GEOGRAPHY_DISPLAY_NAMES = Object.freeze({
69
106
  Tsukrovyi: 'Сахарный',
70
107
  Karakamysh: 'Каракамыш', Sebzar: 'Себзар', Tashselmash: 'Ташсельмаш', Aviasozlar: 'Авиасозлар',
71
108
  Kuylyuk: 'Куйлюк', Sergeli: 'Сергели массив', Sputnik: 'Спутник', 'Yangi Choshtepa': 'Янги Чоштепа',
109
+ Olympia: 'Олимпия', Olimpiya: 'Олимпия', Dustlik: 'Дустлик', Karasu: 'Карасу',
110
+ Traktorsozlar: 'Тракторсозлар', TTZ: 'ТТЗ', Qiyot: 'Кият',
72
111
  // Same places as Choshtepa/Takhtapul above, but under the spelling the
73
112
  // mahalla registry (uz-location-extensions.js) actually uses as its
74
113
  // canonical name -- exact-key lookup means both spellings must be here.
@@ -102,6 +141,16 @@ function languageKey(locale) {
102
141
  return String(locale || 'en').toLowerCase().split(/[-_]/)[0];
103
142
  }
104
143
 
144
+ function numberedMicrodistrictDisplayName(value, locale) {
145
+ if (languageKey(locale) !== 'ru') return null;
146
+ const text = String(value || '').trim();
147
+ const match = text.match(/^(.+?)[\s-]+(\d{1,2}[A-Za-zА-Яа-я]?)$/u);
148
+ if (!match) return null;
149
+ const base = GEOGRAPHY_DISPLAY_NAMES.ru.microdistrict?.[match[1]]
150
+ || GEOGRAPHY_DISPLAY_NAMES.ru.district?.[match[1]];
151
+ return base ? `${base}-${match[2]}` : null;
152
+ }
153
+
105
154
  function preferredEntityLabel(entry, locale) {
106
155
  if (!entry) return null;
107
156
  const key = languageKey(locale);
@@ -119,7 +168,70 @@ function countryEntity(value) {
119
168
  return code ? countryByCode(code) : null;
120
169
  }
121
170
 
122
- export function geographyDisplayName(value, locale = 'en', kind = 'any') {
171
+ function locationEntryFor(value, kind, context) {
172
+ const keys = LOCATION_KIND_KEYS[kind];
173
+ const country = canonicalCountryCode(context?.country);
174
+ const city = String(context?.city || '').trim();
175
+ if (!keys || !country || !city) return null;
176
+ const dictionary = dictionaryFor(country, city);
177
+ if (!dictionary) return null;
178
+ for (const key of keys) {
179
+ const entry = (dictionary[key] || []).find((item) => (item.canonical || item.name) === value);
180
+ if (entry) return entry;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function bestAlias(aliases, score) {
186
+ let best = null;
187
+ let bestScore = -Infinity;
188
+ for (const alias of aliases) {
189
+ const value = String(alias || '').trim();
190
+ if (!value) continue;
191
+ const valueScore = score(value);
192
+ if (valueScore > bestScore || (valueScore === bestScore && best && value.length < best.length)) {
193
+ best = value;
194
+ bestScore = valueScore;
195
+ }
196
+ }
197
+ return bestScore > 0 ? best : null;
198
+ }
199
+
200
+ function locationAliasLabel(entry, locale) {
201
+ if (!entry) return null;
202
+ const language = languageKey(locale);
203
+ const explicit = entry.labels?.[language];
204
+ if (explicit) return explicit;
205
+ const canonical = entry.canonical || entry.name || null;
206
+ if (language === 'en') return entry.labels?.en || canonical;
207
+
208
+ const aliases = aliasesOf(entry);
209
+ if (language === 'ru') {
210
+ return bestAlias(aliases, (alias) => {
211
+ if (!CYRILLIC_RE.test(alias)) return -10;
212
+ let score = 5;
213
+ if (RUSSIAN_GENERIC_RE.test(alias)) score += 8;
214
+ if (UZBEK_CYRILLIC_RE.test(alias)) score -= 4;
215
+ return score;
216
+ }) || canonical;
217
+ }
218
+
219
+ if (language === 'uz') {
220
+ return bestAlias(aliases, (alias) => {
221
+ let score = 0;
222
+ if (LATIN_RE.test(alias)) score += 3;
223
+ if (UZBEK_LATIN_MARKER_RE.test(alias)) score += 10;
224
+ if (UZBEK_CYRILLIC_RE.test(alias)) score += 6;
225
+ if (CYRILLIC_RE.test(alias) && !UZBEK_CYRILLIC_RE.test(alias)) score -= 3;
226
+ if (ENGLISH_GENERIC_RE.test(alias)) score -= 8;
227
+ return score;
228
+ }) || canonical;
229
+ }
230
+
231
+ return canonical;
232
+ }
233
+
234
+ export function geographyDisplayName(value, locale = 'en', kind = 'any', context = null) {
123
235
  const text = String(value || '').trim();
124
236
  if (!text) return '';
125
237
  const language = languageKey(locale);
@@ -131,9 +243,17 @@ export function geographyDisplayName(value, locale = 'en', kind = 'any') {
131
243
  }
132
244
  if (kind === 'city') return tables?.city?.[text] || canonicalEntityLabel(CITIES, text, locale) || text;
133
245
  if (kind === 'region') return tables?.region?.[text] || canonicalEntityLabel(REGIONS, text, locale) || text;
246
+
247
+ const localEntry = locationEntryFor(text, kind, context);
248
+ if (localEntry) {
249
+ const localized = locationAliasLabel(localEntry, locale);
250
+ if (localized) return localized;
251
+ }
252
+
134
253
  if (kind === 'district') return tables?.district?.[text] || text;
135
- if (kind === 'microdistrict') return tables?.microdistrict?.[text] || text;
254
+ if (kind === 'microdistrict') return tables?.microdistrict?.[text] || numberedMicrodistrictDisplayName(text, locale) || text;
136
255
  if (kind === 'metro') return tables?.metro?.[text] || text;
256
+ if (LOCATION_KIND_KEYS[kind]) return text;
137
257
  return tables?.country?.[canonicalCountryCode(text)] || tables?.city?.[text] || tables?.region?.[text] || tables?.district?.[text] || tables?.microdistrict?.[text] || tables?.metro?.[text] || canonicalEntityLabel(CITIES, text, locale) || canonicalEntityLabel(REGIONS, text, locale) || preferredEntityLabel(countryEntity(text), locale) || text;
138
258
  }
139
259
 
@@ -1,9 +1,11 @@
1
1
  export type SalaryCurrencySource = 'explicit' | 'country-default' | 'language-default' | 'unknown';
2
+ export type SalaryPeriodSource = 'explicit' | 'country-default' | 'unknown';
2
3
 
3
4
  export interface HiringSalaryContextOptions {
4
5
  country?: string | null;
5
6
  location?: string | null;
6
7
  currencyFallback?: 'country' | 'language';
8
+ periodFallback?: 'country';
7
9
  }
8
10
 
9
11
  export interface ParsedHiringSalaryWithContext {
@@ -16,11 +18,15 @@ export interface ParsedHiringSalaryWithContext {
16
18
  approximate: boolean;
17
19
  currencySource: SalaryCurrencySource;
18
20
  currencyCountry: string | null;
21
+ periodSource: SalaryPeriodSource;
22
+ periodCountry: string | null;
19
23
  [key: string]: unknown;
20
24
  }
21
25
 
22
26
  export const COUNTRY_DEFAULT_CURRENCIES: Readonly<Record<string, string>>;
27
+ export const COUNTRY_DEFAULT_SALARY_PERIODS: Readonly<Record<string, string>>;
23
28
  export function defaultCurrencyForCountry(value: unknown): string | null;
29
+ export function defaultSalaryPeriodForCountry(value: unknown): string | null;
24
30
  export function parseHiringSalaryWithContext(
25
31
  value: unknown,
26
32
  options?: HiringSalaryContextOptions,
@@ -18,11 +18,31 @@ export const COUNTRY_DEFAULT_CURRENCIES = Object.freeze({
18
18
  GE: 'GEL',
19
19
  });
20
20
 
21
+ // These markets conventionally quote ordinary vacancy salaries per month when
22
+ // a posting gives a local compensation amount but omits an explicit period.
23
+ // Keep this deliberately narrow and opt-in: US/GB postings, for example, must
24
+ // not silently become monthly because annual/hourly quoting is also common.
25
+ export const COUNTRY_DEFAULT_SALARY_PERIODS = Object.freeze({
26
+ UA: 'month',
27
+ UZ: 'month',
28
+ KZ: 'month',
29
+ KG: 'month',
30
+ RO: 'month',
31
+ PL: 'month',
32
+ TR: 'month',
33
+ GE: 'month',
34
+ });
35
+
21
36
  export function defaultCurrencyForCountry(value) {
22
37
  const country = detectCountryCodeFromText(value);
23
38
  return country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
24
39
  }
25
40
 
41
+ export function defaultSalaryPeriodForCountry(value) {
42
+ const country = detectCountryCodeFromText(value);
43
+ return country ? COUNTRY_DEFAULT_SALARY_PERIODS[country] || null : null;
44
+ }
45
+
26
46
  function contextCountry(options) {
27
47
  return detectCountryCodeFromText(options.country)
28
48
  || detectCountryCodeFromText(options.location)
@@ -39,45 +59,59 @@ function languageDefaultCountry(value) {
39
59
  return null;
40
60
  }
41
61
 
42
- function withCurrencyContext(parsed, value, options) {
62
+ function withContext(parsed, value, options) {
43
63
  if (!parsed) return null;
44
- if (parsed.currency) {
45
- return Object.freeze({
46
- ...parsed,
47
- currencySource: 'explicit',
48
- currencyCountry: null,
49
- });
64
+
65
+ let currency = parsed.currency;
66
+ let currencySource = 'explicit';
67
+ let currencyCountry = null;
68
+ if (!currency) {
69
+ let country = null;
70
+ if (options.currencyFallback === 'country') country = contextCountry(options);
71
+ else if (options.currencyFallback === 'language') country = languageDefaultCountry(value);
72
+ currency = country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
73
+ currencySource = currency
74
+ ? options.currencyFallback === 'language' ? 'language-default' : 'country-default'
75
+ : 'unknown';
76
+ currencyCountry = currency ? country : null;
50
77
  }
51
78
 
52
- let country = null;
53
- if (options.currencyFallback === 'country') country = contextCountry(options);
54
- else if (options.currencyFallback === 'language') country = languageDefaultCountry(value);
79
+ let period = parsed.period;
80
+ let periodSource = period ? 'explicit' : 'unknown';
81
+ let periodCountry = null;
82
+ if (!period && options.periodFallback === 'country') {
83
+ const country = contextCountry(options);
84
+ const fallback = country ? COUNTRY_DEFAULT_SALARY_PERIODS[country] || null : null;
85
+ if (fallback) {
86
+ period = fallback;
87
+ periodSource = 'country-default';
88
+ periodCountry = country;
89
+ }
90
+ }
55
91
 
56
- const currency = country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
57
92
  return Object.freeze({
58
93
  ...parsed,
59
94
  currency,
60
- currencySource: currency
61
- ? options.currencyFallback === 'language' ? 'language-default' : 'country-default'
62
- : 'unknown',
63
- currencyCountry: currency ? country : null,
95
+ period,
96
+ currencySource,
97
+ currencyCountry,
98
+ periodSource,
99
+ periodCountry,
64
100
  });
65
101
  }
66
102
 
67
103
  /**
68
- * Parse salary text and optionally infer a missing currency from geography or
69
- * unambiguous local salary-language markers.
104
+ * Parse salary text and optionally infer missing currency/period from geography
105
+ * or unambiguous local salary-language markers.
70
106
  *
71
- * The fallback is deliberately opt-in. A bare number must never silently become
72
- * USD merely because a consumer happens to normalize all salaries to USD later.
73
- * Provenance is returned in `currencySource` so consumers can distinguish an
74
- * explicit currency from a contextual fallback.
107
+ * Fallbacks are deliberately opt-in. Provenance is returned so consumers can
108
+ * distinguish explicit values from contextual defaults.
75
109
  */
76
110
  export function parseHiringSalaryWithContext(value, options = {}) {
77
- return withCurrencyContext(parseSalary(value), value, options);
111
+ return withContext(parseSalary(value), value, options);
78
112
  }
79
113
 
80
- const VACANCY_COMPENSATION_RE = /(?:salary|salary\s+range|base\s+pay|pay\s+range|annual\s+pay|compensation(?:\s+range)?|заработн\p{L}*\s+плат\p{L}*|зарплат\p{L}*|оклад\p{L}*|вилка\s+оплат\p{L}*|оплата\s+труда|компенсац\p{L}*|ставка)/giu;
114
+ const VACANCY_COMPENSATION_RE = /(?:salary|salary\s+range|base\s+pay|pay\s+range|annual\s+pay|compensation(?:\s+range)?|заработн\p{L}*\s+плат\p{L}*|зарплат\p{L}*|з\s*[\/\\.\-]?\s*п(?=$|[^\p{L}\p{N}_])|оклад\p{L}*|вилка\s+оплат\p{L}*|оплата\s+труда|компенсац\p{L}*|ставка|💵|💰)/giu;
81
115
 
82
116
  // AI-recruiting/staffing postings (e.g. Mercor) routinely mention funding,
83
117
  // valuation or revenue figures in the same listing as the actual salary. A
@@ -1,5 +1,11 @@
1
1
  export type HiringEducation = 'doctorate' | 'master' | 'bachelor' | 'higher' | 'secondary';
2
2
  export type HiringApplicationLanguage = 'English' | 'Russian' | 'Ukrainian' | 'Uzbek' | 'Kazakh' | 'Romanian';
3
3
 
4
+ export interface HiringAgeRange {
5
+ min: number | null;
6
+ max: number | null;
7
+ }
8
+
4
9
  export function detectHiringEducation(value: unknown): HiringEducation | null;
5
10
  export function detectApplicationLanguage(value: unknown): HiringApplicationLanguage | null;
11
+ export function extractHiringAgeRange(value: unknown): HiringAgeRange | null;
@@ -1,7 +1,14 @@
1
1
  import { detectDegreeLevel } from './hiring-requirements.js';
2
+ import { normalizeUnicode } from './normalization.js';
2
3
 
3
4
  export function detectHiringEducation(value) {
4
5
  const text = String(value || '');
6
+ // Vacancy requirements often state "Bachelor's or Master's degree". That is
7
+ // an accepted-alternative list, not a Master's minimum; expose the least
8
+ // restrictive accepted level so filters do not hide valid Bachelor holders.
9
+ if (/(?:bachelor['’]?s?|бакалавр\p{L}*)[^.;\n]{0,40}(?:or|\/|или|або|sau|yoki)[^.;\n]{0,40}(?:master['’]?s?|магистр\p{L}*|магістр\p{L}*)/iu.test(text)
10
+ || /(?:master['’]?s?|магистр\p{L}*|магістр\p{L}*)[^.;\n]{0,40}(?:or|\/|или|або|sau|yoki)[^.;\n]{0,40}(?:bachelor['’]?s?|бакалавр\p{L}*)/iu.test(text)) return 'bachelor';
11
+
5
12
  const degree = detectDegreeLevel(text);
6
13
  if (degree) return degree;
7
14
  if (/higher education|высшее образование|вища освіта|олий маълумот/iu.test(text)) return 'higher';
@@ -21,3 +28,39 @@ export function detectApplicationLanguage(value) {
21
28
  if (/romanian|румын/.test(language)) return 'Romanian';
22
29
  return null;
23
30
  }
31
+
32
+ function validAge(value) {
33
+ const age = Number(value);
34
+ return Number.isFinite(age) && age >= 14 && age <= 90 ? age : null;
35
+ }
36
+
37
+ /** Extract an explicit vacancy candidate-age requirement without confusing it with experience. */
38
+ export function extractHiringAgeRange(value) {
39
+ const text = normalizeUnicode(value ?? '');
40
+ if (!text) return null;
41
+
42
+ const patterns = [
43
+ // Russian/Ukrainian/Romanian/English labels: "Возраст от 18 до 35 лет".
44
+ /(?:возраст|вік|age|vârsta|varsta)\s*[:—-]?[^\d]{0,18}(?:от|від|from|de\s+la)?\s*(\d{1,2})\s*(?:[-–—]|до|to|până\s+la|pana\s+la)\s*(\d{1,2})(?:\s*(?:лет|рок\p{L}*|years?|ani))?/iu,
45
+ // Uzbek Latin/Cyrillic: "18 dan 40 yoshgacha", including common "20 madan" typo.
46
+ /(?:^|[^\d])(\d{1,2})\s*(?:yosh(?:dan)?|ёш(?:дан)?|dan|дан|madan)?\s*(?:[-–—]|dan\s+|дан\s+)?(?:to|до)?\s*(\d{1,2})\s*(?:yoshgacha|ёшгача|yoshga\s+qadar|ёшга\s+қадар)(?=$|[^\p{L}\p{N}_])/iu,
47
+ /(?:yosh|ёш)\s*[:—-]?[^\d]{0,18}(\d{1,2})\s*(?:[-–—]|dan\s+|дан\s+|gacha\s+|гача\s+)(\d{1,2})/iu,
48
+ ];
49
+
50
+ for (const pattern of patterns) {
51
+ const match = text.match(pattern);
52
+ if (!match) continue;
53
+ const first = validAge(match[1]);
54
+ const second = validAge(match[2]);
55
+ if (first == null || second == null) continue;
56
+ return Object.freeze({ min: Math.min(first, second), max: Math.max(first, second) });
57
+ }
58
+
59
+ const minMatch = text.match(/(?:возраст|вік|age|yosh|ёш)\s*[:—-]?[^\d]{0,16}(?:от|від|from|kamida|камида)?\s*(\d{1,2})\s*\+?(?:\s*(?:лет|рок\p{L}*|years?|yosh|ёш))?/iu);
60
+ const minimum = validAge(minMatch?.[1]);
61
+ if (minimum != null) return Object.freeze({ min: minimum, max: null });
62
+
63
+ const uzMax = text.match(/(?:^|[^\d])(\d{1,2})\s*(?:yoshgacha|ёшгача)(?=$|[^\p{L}\p{N}_])/iu);
64
+ const maximum = validAge(uzMax?.[1]);
65
+ return maximum != null ? Object.freeze({ min: null, max: maximum }) : null;
66
+ }
@@ -1,4 +1,4 @@
1
- import { findCanonical } from './normalization.js';
1
+ import { aliasesOf, aliasesToRegex, findCanonical } from './normalization.js';
2
2
  import {
3
3
  EMPLOYMENT_TYPES,
4
4
  EXPERIENCE_REQUIREMENTS,
@@ -27,10 +27,24 @@ const PROBATION_MATCH_ORDER = Object.freeze([
27
27
  PROBATION_TERMS.probation,
28
28
  ]);
29
29
 
30
+ function matchesEmploymentType(text, entry) {
31
+ // `project` is a useful canonical output but a dangerously generic input:
32
+ // ordinary vacancy prose says "projects", "Project Tech Stack" and
33
+ // "international projects" without describing project-based employment.
34
+ // Its multilingual aliases already encode the actual employment semantics
35
+ // ("project work", "project-based", "проектная работа", etc.), so only
36
+ // those aliases are accepted for this one canonical.
37
+ if (entry.canonical === 'project') {
38
+ const aliases = aliasesOf(entry);
39
+ return aliases.length ? aliasesToRegex(aliases).test(text) : false;
40
+ }
41
+ return Boolean(findCanonical(text, [entry], { partial: true }));
42
+ }
43
+
30
44
  function collectCanonical(text, entries) {
31
45
  const values = [];
32
46
  for (const entry of entries) {
33
- if (!findCanonical(text, [entry], { partial: true })) continue;
47
+ if (!matchesEmploymentType(text, entry)) continue;
34
48
  if (!values.includes(entry.canonical)) values.push(entry.canonical);
35
49
  }
36
50
  return values;
package/src/locations.js CHANGED
@@ -53,42 +53,262 @@ const UZ_BASE_LOCATION_DICTIONARIES = Object.freeze({
53
53
  }),
54
54
  });
55
55
 
56
- function normalizeUzSemanticLocations(country) {
57
- const tashkent = country?.Tashkent;
58
- if (!tashkent) return country;
59
- const qorasuv = (tashkent.microdistricts || []).find(({ name }) => name === 'Qorasuv');
60
- if (!qorasuv) return country;
61
-
62
- // Bare Qorasuv is ambiguous with numbered Qorasuv/Karasu blocks. The
63
- // umbrella area therefore requires an area/massif/daha form in free text.
64
- const qorasuvAliases = Object.freeze([...new Set([
65
- ...(qorasuv.aliases || []).filter((alias) => !/^(?:qorasuv|korasuv|корасув|карасу)$/iu.test(String(alias).trim())),
66
- 'Qorasuv dahasi',
67
- 'Qorasuv daha',
68
- 'Қорасув даҳаси',
69
- 'Корасув дахаси',
70
- 'Карасу даха',
71
- ])]);
72
- const qorasuvArea = Object.freeze({
73
- ...qorasuv,
74
- type: 'local_area',
75
- entityType: 'local_area',
76
- parent: 'Mirzo Ulugbek',
77
- aliases: qorasuvAliases,
78
- re: aliasesToRegex(qorasuvAliases),
56
+ function semanticEntry(entry, name, aliases, entityType) {
57
+ const all = Object.freeze([...new Set([name, ...aliases].filter(Boolean))]);
58
+ return Object.freeze({
59
+ ...entry,
60
+ canonical: name,
61
+ name,
62
+ type: entityType,
63
+ entityType,
64
+ aliases: all,
65
+ re: aliasesToRegex(all),
79
66
  });
67
+ }
80
68
 
81
- return Object.freeze({
82
- ...country,
83
- Tashkent: Object.freeze({
84
- ...tashkent,
85
- microdistricts: Object.freeze((tashkent.microdistricts || []).filter(({ name }) => name !== 'Qorasuv')),
86
- localAreas: Object.freeze([
87
- ...(tashkent.localAreas || []),
88
- qorasuvArea,
89
- ]),
69
+ function normalizeUzSemanticLocations(country) {
70
+ let normalized = Object.freeze(Object.fromEntries(
71
+ Object.entries(country || {}).map(([cityName, data]) => {
72
+ const localAreas = data?.localAreas || [];
73
+ if (!localAreas.some(({ name }) => name === 'University area')) return [cityName, data];
74
+
75
+ return [cityName, Object.freeze({
76
+ ...data,
77
+ localAreas: Object.freeze(localAreas.map((entry) => (
78
+ entry.name === 'University area'
79
+ ? semanticEntry(entry, 'University area', [
80
+ ...(entry.aliases || []),
81
+ 'Universitet hududi',
82
+ 'Universitet atrofi',
83
+ 'Университет ҳудуди',
84
+ 'Университет атрофи',
85
+ ], 'local_area')
86
+ : entry
87
+ ))),
88
+ })];
90
89
  }),
91
- });
90
+ ));
91
+ const tashkent = normalized?.Tashkent;
92
+ const qorasuv = (tashkent?.microdistricts || []).find(({ name }) => name === 'Qorasuv');
93
+
94
+ if (tashkent && qorasuv) {
95
+ // Bare Qorasuv is ambiguous with numbered Qorasuv/Karasu blocks. The
96
+ // umbrella area therefore requires an area/massif/daha form in free text.
97
+ const qorasuvAliases = Object.freeze([...new Set([
98
+ ...(qorasuv.aliases || []).filter((alias) => !/^(?:qorasuv|korasuv|корасув|карасу)$/iu.test(String(alias).trim())),
99
+ 'Qorasuv dahasi',
100
+ 'Qorasuv daha',
101
+ 'Қорасув даҳаси',
102
+ 'Корасув дахаси',
103
+ 'Карасу даха',
104
+ ])]);
105
+ const qorasuvArea = Object.freeze({
106
+ ...qorasuv,
107
+ type: 'local_area',
108
+ entityType: 'local_area',
109
+ parent: 'Mirzo Ulugbek',
110
+ aliases: qorasuvAliases,
111
+ re: aliasesToRegex(qorasuvAliases),
112
+ });
113
+
114
+ normalized = Object.freeze({
115
+ ...normalized,
116
+ Tashkent: Object.freeze({
117
+ ...tashkent,
118
+ microdistricts: Object.freeze((tashkent.microdistricts || []).filter(({ name }) => name !== 'Qorasuv')),
119
+ localAreas: Object.freeze([
120
+ ...(tashkent.localAreas || []),
121
+ qorasuvArea,
122
+ ]),
123
+ }),
124
+ });
125
+ }
126
+
127
+ const samarkand = normalized?.Samarkand;
128
+ if (samarkand) {
129
+ const landmarks = samarkand.landmarks || [];
130
+ const localAreas = samarkand.localAreas || [];
131
+ const streets = samarkand.streets || [];
132
+
133
+ // Legacy UZ seeds contain a few Samarkand listing labels as separate
134
+ // canonicals or under the wrong semantic collection. Normalize them to the
135
+ // single physical subjects already represented by geo-catalog.
136
+ const centralPark = landmarks.find(({ name }) => name === 'Central Park');
137
+ const alisherPark = landmarks.find(({ name }) => name === 'Alisher Navoiy Park');
138
+ const canonicalPark = centralPark || alisherPark;
139
+ const parkAliases = [
140
+ ...(centralPark?.aliases || []),
141
+ ...(alisherPark?.aliases || []),
142
+ 'Central Park',
143
+ 'Alisher Navoiy Park',
144
+ ];
145
+ const normalizedPark = canonicalPark
146
+ ? semanticEntry(canonicalPark, 'Central Park', parkAliases, 'poi')
147
+ : null;
148
+
149
+ const siyobBazaar = landmarks.find(({ name }) => name === 'Siyob Bazaar');
150
+ const siabBazaar = landmarks.find(({ name }) => name === 'Siab Bazaar');
151
+ const canonicalBazaar = siyobBazaar || siabBazaar;
152
+ const bazaarAliases = [
153
+ ...(siyobBazaar?.aliases || []),
154
+ ...(siabBazaar?.aliases || []),
155
+ 'Siyob Bazaar',
156
+ 'Siab Bazaar',
157
+ ];
158
+ const normalizedBazaar = canonicalBazaar
159
+ ? semanticEntry(canonicalBazaar, 'Siyob Bazaar', bazaarAliases, 'poi')
160
+ : null;
161
+
162
+ const universityLocalArea = localAreas.find(({ name }) => name === 'University Boulevard');
163
+ const universityLandmark = landmarks.find(({ name }) => name === 'University Boulevard');
164
+ const universityStreet = streets.find(({ name }) => name === 'University Boulevard');
165
+ const canonicalUniversity = universityStreet || universityLandmark || universityLocalArea;
166
+ const universityAliases = [
167
+ ...(universityStreet?.aliases || []),
168
+ ...(universityLandmark?.aliases || []),
169
+ ...(universityLocalArea?.aliases || []),
170
+ 'University Boulevard',
171
+ ];
172
+ const normalizedUniversity = canonicalUniversity
173
+ ? semanticEntry(canonicalUniversity, 'University Boulevard', universityAliases, 'street')
174
+ : null;
175
+
176
+ normalized = Object.freeze({
177
+ ...normalized,
178
+ Samarkand: Object.freeze({
179
+ ...samarkand,
180
+ localAreas: Object.freeze(localAreas.filter(({ name }) => name !== 'University Boulevard')),
181
+ streets: Object.freeze([
182
+ ...streets.filter(({ name }) => name !== 'University Boulevard'),
183
+ ...(normalizedUniversity ? [normalizedUniversity] : []),
184
+ ]),
185
+ landmarks: Object.freeze([
186
+ ...landmarks.filter(({ name }) => ![
187
+ 'Central Park',
188
+ 'Alisher Navoiy Park',
189
+ 'Siyob Bazaar',
190
+ 'Siab Bazaar',
191
+ 'University Boulevard',
192
+ 'Samarkand City',
193
+ ].includes(name)),
194
+ ...(normalizedPark ? [normalizedPark] : []),
195
+ ...(normalizedBazaar ? [normalizedBazaar] : []),
196
+ ]),
197
+ }),
198
+ });
199
+ }
200
+
201
+ const khiva = normalized?.Khiva;
202
+ if (khiva) {
203
+ const localAreas = khiva.localAreas || [];
204
+ const ichanKala = localAreas.find(({ name }) => name === 'Ichan Kala');
205
+ const oldCity = localAreas.find(({ name }) => name === 'Old City');
206
+
207
+ if (ichanKala && oldCity) {
208
+ // UNESCO identifies Itchan Kala as the historic inner-city of Khiva.
209
+ // Keep listing-friendly Old City forms as aliases of that one place.
210
+ const normalizedIchanKala = semanticEntry(ichanKala, 'Ichan Kala', [
211
+ ...(ichanKala.aliases || []),
212
+ ...(oldCity.aliases || []),
213
+ 'Old City',
214
+ ], 'local_area');
215
+
216
+ normalized = Object.freeze({
217
+ ...normalized,
218
+ Khiva: Object.freeze({
219
+ ...khiva,
220
+ localAreas: Object.freeze([
221
+ ...localAreas.filter(({ name }) => !['Ichan Kala', 'Old City'].includes(name)),
222
+ normalizedIchanKala,
223
+ ]),
224
+ }),
225
+ });
226
+ }
227
+ }
228
+
229
+ const angren = normalized?.Angren;
230
+ if (angren) {
231
+ const microdistricts = angren.microdistricts || [];
232
+ const localAreas = angren.localAreas || [];
233
+ const mahallas = angren.mahallas || [];
234
+ const streets = angren.streets || [];
235
+ const legacyMicrodistrictNames = new Set([
236
+ '1 microdistrict',
237
+ '2 microdistrict',
238
+ '3 microdistrict',
239
+ '4 microdistrict',
240
+ '5 microdistrict',
241
+ ]);
242
+
243
+ const quarterRows = Object.freeze([
244
+ ['2 quarter', ['2-daha','2 daha','2 dahasi','2-й квартал','2 квартал','2 microdistrict','2 микрорайон','2-mikrorayon','2 mikrorayon','2 мкр']],
245
+ ['3 quarter', ['3-daha','3 daha','3 dahasi','3-й квартал','3 квартал','3 microdistrict','3 микрорайон','3-mikrorayon','3 mikrorayon','3 мкр']],
246
+ ['5 quarter', ['5-daha','5 daha','5 dahasi','5-й квартал','5 квартал','5 microdistrict','5 микрорайон','5-mikrorayon','5 mikrorayon','5 мкр']],
247
+ ['6 quarter', ['6-daha','6 daha','6 dahasi','6-й квартал','6 квартал']],
248
+ ['7 quarter', ['7-daha','7 daha','7 dahasi','7-й квартал','7 квартал']],
249
+ ['8 quarter', ['8-daha','8 daha','8 dahasi','8-й квартал','8 квартал']],
250
+ ['9 quarter', ['9-daha','9 daha','9 dahasi','9-й квартал','9 квартал']],
251
+ ['10 quarter', ['10-daha','10 daha','10 dahasi','10-й квартал','10 квартал']],
252
+ ['11 quarter', ['11-daha','11 daha','11 dahasi','11-й квартал','11 квартал']],
253
+ ['32 quarter', ['32-daha','32 daha','32 dahasi','32-й квартал','32 квартал']],
254
+ ['2/2 quarter', ['2/2-daha','2/2 daha','2/2 dahasi','2/2 квартал']],
255
+ ['2/5 quarter', ['2/5-daha','2/5 daha','2/5 dahasi','2/5 квартал']],
256
+ ['3/2 quarter', ['3/2-daha','3/2 daha','3/2 dahasi','3/2 квартал']],
257
+ ['3/3 quarter', ['3/3-daha','3/3 daha','3/3 dahasi','3/3 квартал']],
258
+ ['4/5 quarter', ['4/5-daha','4/5 daha','4/5 dahasi','4/5 квартал']],
259
+ ['4/6 quarter', ['4/6-daha','4/6 daha','4/6 dahasi','4/6 квартал']],
260
+ ['5/1A quarter', ['5/1A-daha','5/1A daha','5/1A dahasi','5/1-A daha','5/1-A dahasi','5/1A квартал','5/1-A квартал']],
261
+ ['5/1B quarter', ['5/1B-daha','5/1B daha','5/1B dahasi','5/1-B daha','5/1-B dahasi','5/1B квартал','5/1-B квартал']],
262
+ ['5/3 quarter', ['5/3-daha','5/3 daha','5/3 dahasi','5/3 квартал']],
263
+ ['5/4 quarter', ['5/4-daha','5/4 daha','5/4 dahasi','5/4 квартал']],
264
+ ['5/5 quarter', ['5/5-daha','5/5 daha','5/5 dahasi','5/5 квартал']],
265
+ ['6/4 quarter', ['6/4-daha','6/4 daha','6/4 dahasi','6/4 квартал']],
266
+ ['18/19 quarter', ['18/19-daha','18/19 daha','18/19 dahasi','18/19 квартал']],
267
+ ]);
268
+ const verifiedQuarterNames = new Set(quarterRows.map(([name]) => name));
269
+ const quarterEntries = quarterRows.map(([name, aliases]) => semanticEntry({}, name, aliases, 'microdistrict'));
270
+
271
+ const geologLocalArea = localAreas.find(({ name }) => name === 'Geolog');
272
+ const geologAliases = [
273
+ ...(geologLocalArea?.aliases || []),
274
+ 'Geolog MFY',
275
+ 'Geolog mahallasi',
276
+ "Geolog mahalla fuqarolar yig'ini",
277
+ 'Геолог МФЙ',
278
+ 'махалля Геолог',
279
+ ];
280
+ const geologMahalla = semanticEntry(geologLocalArea || {}, 'Geolog', geologAliases, 'mahalla');
281
+
282
+ const verifiedStreetRows = Object.freeze([
283
+ ['Amir Temur Street', ['Amir Temur ko‘chasi',"Amir Temur ko'chasi",'улица Амира Темура','ул. Амира Темура']],
284
+ ['Bunyodkor Street', ['Bunyodkor ko‘chasi',"Bunyodkor ko'chasi",'Бунёдкор кўчаси','улица Бунёдкор','ул. Бунёдкор']],
285
+ ['Ohangaron Street', ['Ohangaron ko‘chasi',"Ohangaron ko'chasi",'Оҳангарон кўчаси','улица Ахангаран','улица Охангарон']],
286
+ ]);
287
+ const verifiedStreetNames = new Set(verifiedStreetRows.map(([name]) => name));
288
+ const verifiedStreets = verifiedStreetRows.map(([name, aliases]) => semanticEntry({}, name, aliases, 'street'));
289
+
290
+ normalized = Object.freeze({
291
+ ...normalized,
292
+ Angren: Object.freeze({
293
+ ...angren,
294
+ mahallas: Object.freeze([
295
+ ...mahallas.filter(({ name }) => name !== 'Geolog'),
296
+ geologMahalla,
297
+ ]),
298
+ microdistricts: Object.freeze([
299
+ ...microdistricts.filter(({ name }) => !legacyMicrodistrictNames.has(name) && !verifiedQuarterNames.has(name)),
300
+ ...quarterEntries,
301
+ ]),
302
+ localAreas: Object.freeze(localAreas.filter(({ name }) => name !== 'Geolog')),
303
+ streets: Object.freeze([
304
+ ...streets.filter(({ name }) => !verifiedStreetNames.has(name)),
305
+ ...verifiedStreets,
306
+ ]),
307
+ }),
308
+ });
309
+ }
310
+
311
+ return normalized;
92
312
  }
93
313
 
94
314
  const ODESA_FONTAN_STATION_RE = /^(?:[5-9]|1[0-6]) Fontan Station$/u;
package/src/money-core.js CHANGED
@@ -8,7 +8,10 @@ import {
8
8
  // Monetary values in job descriptions commonly combine thousands grouping with
9
9
  // decimals (e.g. 137,000.00 or 137.000,00). Keep the grouped variants ahead of
10
10
  // the generic decimal form so a range parser consumes the complete endpoint.
11
- export const MONEY_NUMBER_PATTERN = '(?:\\d{1,3}(?:[ \\u00a0]\\d{3})+(?:[.,]\\d+)?|\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?|\\d+(?:[.,]\\d+)?)';
11
+ // Local boards also occasionally split a salary with a mistyped first group
12
+ // ("4 00 000" instead of "400 000"). Accept that only when there are at least
13
+ // two spaced groups, which keeps ordinary two-number prose out of money parsing.
14
+ export const MONEY_NUMBER_PATTERN = '(?:\\d{1,3}(?:[ \\u00a0]\\d{2,3}){2,}(?:[.,]\\d+)?|\\d{1,3}(?:[ \\u00a0]\\d{3})+(?:[.,]\\d+)?|\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?|\\d+(?:[.,]\\d+)?)';
12
15
  export const MONEY_SCALE_PATTERN = 'k|к|тыс\\.?|тысяч(?:а|и)?|тис\\.?|thousand|ming|мың|m|м|млн\\.?|mln|million|миллион(?:ов)?|мільйон(?:ів)?|bn|млрд|mlrd|billion';
13
16
  // Each scale group needs the token-boundary guard MONEY_SINGLE_RE already has
14
17
  // below: without it, "2 до 3 месяцев" reads "м" off "месяцев" as the million
package/src/money.js CHANGED
@@ -32,9 +32,11 @@ export {
32
32
  // following phone-like number as a protected contact span.
33
33
  const CONTACT_MARKER_RE = /(?<![\p{L}\p{N}_])(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
34
34
  const JOBS_I18N_PERIOD_RE = /\bjobs\.per(hour|day|shift|week|month|year|project|piece)\b/iu;
35
+ const TIME_RANGE_RE = /(?<!\d)(?:[01]?\d|2[0-3])[:.][0-5]\d\s*(?:[-–—]\s*(?:[01]?\d|2[0-3])[:.][0-5]\d)?(?!\d)/gu;
36
+ const WORK_RATIO_RE = /(?<!\d)[1-7]\s*\/\s*[1-7](?!\d)/gu;
35
37
 
36
38
  function hasSalaryContext(text) {
37
- return /(?:salary|зарплат|з\s*п\b|оплат|ставк|доход|оклад|компенсац|maosh|oylik|ish\s+haqi|жалақы|айлық|еңбекақы|salariu|оплата)/iu.test(text);
39
+ return /(?:salary|зарплат|заработ\p{L}*\s+плат|з\s*[\/\\.\-]?\s*п(?=$|[^\p{L}\p{N}_])|оплат|ставк|доход|оклад|компенсац|maosh|oylik|ish\s+haqi|жалақы|айлық|еңбекақы|salariu|💵|💰)/iu.test(text);
38
40
  }
39
41
 
40
42
  function periodFromText(text) {
@@ -57,7 +59,26 @@ function protectedPhoneSpans(text) {
57
59
  });
58
60
  }
59
61
 
60
- function overlapsProtectedPhone(start, end, spans) {
62
+ function regexSpans(text, pattern) {
63
+ const spans = [];
64
+ pattern.lastIndex = 0;
65
+ for (const match of text.matchAll(pattern)) {
66
+ const start = match.index ?? 0;
67
+ spans.push({ start, end: start + match[0].length });
68
+ }
69
+ pattern.lastIndex = 0;
70
+ return spans;
71
+ }
72
+
73
+ function protectedNonMoneySpans(text) {
74
+ return [
75
+ ...protectedPhoneSpans(text),
76
+ ...regexSpans(text, TIME_RANGE_RE),
77
+ ...regexSpans(text, WORK_RATIO_RE),
78
+ ];
79
+ }
80
+
81
+ function overlapsProtectedSpan(start, end, spans) {
61
82
  return spans.some((span) => start < span.end && end > span.start);
62
83
  }
63
84
 
@@ -75,7 +96,7 @@ function rangeSearchText(text) {
75
96
  // MONEY_RANGE_RE intentionally parses numeric structure only. Currency symbols
76
97
  // may legally repeat around both endpoints ("$55 — $65"); blank them with
77
98
  // equal-length whitespace so the range parser can see the numbers while all
78
- // original indices still line up with phone protection/context scoring.
99
+ // original indices still line up with contact/time protection and scoring.
79
100
  let normalized = text;
80
101
  for (const symbol of Object.keys(CURRENCY_SYMBOL_CANDIDATES)) {
81
102
  normalized = normalized.split(symbol).join(' '.repeat(symbol.length));
@@ -90,7 +111,7 @@ function bestRange(text, protectedSpans) {
90
111
  for (const match of searchable.matchAll(ranges)) {
91
112
  const start = match.index ?? 0;
92
113
  const end = start + match[0].length;
93
- if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
114
+ if (overlapsProtectedSpan(start, end, protectedSpans)) continue;
94
115
  const scaled = Boolean(match[2] || match[4]);
95
116
  const score = moneyContextScore(text, start, end, scaled);
96
117
  if (score <= 0) continue;
@@ -114,7 +135,7 @@ export function parseSalary(value) {
114
135
  || NUMBER_MULTIPLIERS.some((entry) => findCanonical(text, [entry], { partial: true }));
115
136
  if (!salaryContext && !negotiable) return null;
116
137
 
117
- const protectedSpans = protectedPhoneSpans(text);
138
+ const protectedSpans = protectedNonMoneySpans(text);
118
139
  const range = bestRange(text, protectedSpans);
119
140
  let min = null;
120
141
  let max = null;
@@ -132,7 +153,7 @@ export function parseSalary(value) {
132
153
  for (const match of text.matchAll(MONEY_SINGLE_RE)) {
133
154
  const start = match.index ?? 0;
134
155
  const end = start + match[0].length;
135
- if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
156
+ if (overlapsProtectedSpan(start, end, protectedSpans)) continue;
136
157
  const window = text.slice(Math.max(0, start - 24), Math.min(text.length, end + 32));
137
158
  const scaled = Boolean(match[2]);
138
159
  const score = moneyContextScore(text, start, end, scaled);
@@ -344,7 +344,10 @@ export const UZ_LOCATION_EXTENSIONS = Object.freeze({
344
344
 
345
345
  Navoiy: city({
346
346
  mahallas: [{ name: 'Guliston', aliases: ['Гулистон'], confidence: 'official' }],
347
- microdistricts: numberedMicrodistricts([1,2,3,4,5,6,7,8,9,10,11,12]),
347
+ microdistricts: [
348
+ ...numberedMicrodistricts([1,2,3,4,5,6,7,8,9,10,11,12]),
349
+ ['17 microdistrict','17-kichik nohiya','17 kichik nohiya','17-й микрорайон','17 микрорайон','17-mikrorayon','17 mikrorayon','17 мкр'],
350
+ ],
348
351
  localAreas: [['Uzbekiston Massiv',"O'zbekiston massivi",'Узбекистон массив','Ўзбекистон массиви'],['Yangi Navoiy','Янги Навои','Янги Навоий'],['Center','Markaz','Центр'],['Sputnik','Спутник'],['Railway Station area','Вокзал']],
349
352
  landmarks: [['Alisher Navoiy Park',"Alisher Navoiy bog'i",'парк Алишера Навои'],['Farhod Palace of Culture','Farhod madaniyat saroyi','Дворец культуры Фархад'],['Navoiy Mining and Metallurgical Company','Navoiy kon-metallurgiya kombinati','НГМК','NGMK','Навоийский ГМК'],['Navoi International Airport','Navoiy aeroporti','NVI']],
350
353
  }),
@@ -370,7 +373,13 @@ export const UZ_LOCATION_EXTENSIONS = Object.freeze({
370
373
 
371
374
  Almalyk: city({
372
375
  mahallas: [{ name: 'Kamalak', aliases: ['Камалак','Радуга'], confidence: 'official' }],
373
- microdistricts: numberedMicrodistricts([1,2,3,4,5]),
376
+ microdistricts: [
377
+ ...numberedMicrodistricts([1,2,3,4,5]),
378
+ ['5/1 microdistrict','5/1-kichik nohiya','5/1 kichik nohiya','5/1 микрорайон'],
379
+ ['5/2 microdistrict','5/2-kichik nohiya','5/2 kichik nohiya','5/2 микрорайон'],
380
+ ['5/3 microdistrict','5/3-kichik nohiya','5/3 kichik nohiya','5/3 микрорайон'],
381
+ ['Yubileyny microdistrict','Yubileyny','Yubiley kichi- nohiya','Yubiley kichik nohiya','Юбилейный микрорайон','Юбилейный'],
382
+ ],
374
383
  localAreas: [['Center','Markaz','Центр'],['Old City','Eski shahar','Старый город'],['New City','Yangi shahar','Новый город'],['Metallurg','Металлург'],['Sports Palace area','Дворец спорта'],['Railway Station area','Вокзал']],
375
384
  landmarks: [['Almalyk MMC','АГМК','AGMK','ОКМК','OKMK','Олмалиқ КМК','Almalyk Mining and Metallurgical Complex'],['Metallurg Stadium','Metallurg stadioni','стадион Металлург']],
376
385
  }),