@whiteslove/parsing-lexicon 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,6 +69,8 @@ export const GEOGRAPHY_DISPLAY_NAMES = Object.freeze({
69
69
  Tsukrovyi: 'Сахарный',
70
70
  Karakamysh: 'Каракамыш', Sebzar: 'Себзар', Tashselmash: 'Ташсельмаш', Aviasozlar: 'Авиасозлар',
71
71
  Kuylyuk: 'Куйлюк', Sergeli: 'Сергели массив', Sputnik: 'Спутник', 'Yangi Choshtepa': 'Янги Чоштепа',
72
+ Olympia: 'Олимпия', Olimpiya: 'Олимпия', Dustlik: 'Дустлик', Karasu: 'Карасу',
73
+ Traktorsozlar: 'Тракторсозлар', TTZ: 'ТТЗ', Qiyot: 'Кият',
72
74
  // Same places as Choshtepa/Takhtapul above, but under the spelling the
73
75
  // mahalla registry (uz-location-extensions.js) actually uses as its
74
76
  // canonical name -- exact-key lookup means both spellings must be here.
@@ -102,6 +104,16 @@ function languageKey(locale) {
102
104
  return String(locale || 'en').toLowerCase().split(/[-_]/)[0];
103
105
  }
104
106
 
107
+ function numberedMicrodistrictDisplayName(value, locale) {
108
+ if (languageKey(locale) !== 'ru') return null;
109
+ const text = String(value || '').trim();
110
+ const match = text.match(/^(.+?)[\s-]+(\d{1,2}[A-Za-zА-Яа-я]?)$/u);
111
+ if (!match) return null;
112
+ const base = GEOGRAPHY_DISPLAY_NAMES.ru.microdistrict?.[match[1]]
113
+ || GEOGRAPHY_DISPLAY_NAMES.ru.district?.[match[1]];
114
+ return base ? `${base}-${match[2]}` : null;
115
+ }
116
+
105
117
  function preferredEntityLabel(entry, locale) {
106
118
  if (!entry) return null;
107
119
  const key = languageKey(locale);
@@ -132,7 +144,7 @@ export function geographyDisplayName(value, locale = 'en', kind = 'any') {
132
144
  if (kind === 'city') return tables?.city?.[text] || canonicalEntityLabel(CITIES, text, locale) || text;
133
145
  if (kind === 'region') return tables?.region?.[text] || canonicalEntityLabel(REGIONS, text, locale) || text;
134
146
  if (kind === 'district') return tables?.district?.[text] || text;
135
- if (kind === 'microdistrict') return tables?.microdistrict?.[text] || text;
147
+ if (kind === 'microdistrict') return tables?.microdistrict?.[text] || numberedMicrodistrictDisplayName(text, locale) || text;
136
148
  if (kind === 'metro') return tables?.metro?.[text] || text;
137
149
  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
150
  }
@@ -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;
@@ -13,6 +13,14 @@ export interface HousingListingEnrichment {
13
13
  privateYard?: boolean | null;
14
14
  dishwasher?: boolean | null;
15
15
  airConditioner?: boolean | null;
16
+ tv?: boolean | null;
17
+ microwave?: boolean | null;
18
+ oven?: boolean | null;
19
+ bidet?: boolean | null;
20
+ walkInCloset?: boolean | null;
21
+ bathtub?: boolean | null;
22
+ shower?: boolean | null;
23
+ euroLayout?: boolean | null;
16
24
  gas?: boolean | null;
17
25
  newBuilding?: boolean | null;
18
26
  communalSeparated?: boolean | null;
@@ -218,6 +218,14 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
218
218
  privateYard: listingFields.privateYard ?? null,
219
219
  dishwasher: listingFields.dishwasher ?? null,
220
220
  airConditioner: listingFields.airConditioner ?? (AIR_CONDITIONER_RE.test(text) ? true : null),
221
+ tv: listingFields.tv ?? null,
222
+ microwave: listingFields.microwave ?? null,
223
+ oven: listingFields.oven ?? null,
224
+ bidet: listingFields.bidet ?? null,
225
+ walkInCloset: listingFields.walkInCloset ?? null,
226
+ bathtub: listingFields.bathtub ?? null,
227
+ shower: listingFields.shower ?? null,
228
+ euroLayout: listingFields.euroLayout ?? null,
221
229
  gas: listingFields.gas ?? null,
222
230
  newBuilding: listingFields.newBuilding ?? null,
223
231
  communalSeparated: listingFields.communalSeparated ?? null,
@@ -20,6 +20,14 @@ export type HousingListingFields = Readonly<{
20
20
  gazebo: boolean | null;
21
21
  dishwasher: boolean | null;
22
22
  airConditioner: boolean | null;
23
+ tv: boolean | null;
24
+ microwave: boolean | null;
25
+ oven: boolean | null;
26
+ bidet: boolean | null;
27
+ walkInCloset: boolean | null;
28
+ bathtub: boolean | null;
29
+ shower: boolean | null;
30
+ euroLayout: boolean | null;
23
31
  gas: boolean | null;
24
32
  newBuilding: boolean | null;
25
33
  communalSeparated: boolean | null;
@@ -137,6 +137,59 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
137
137
  /кондицион|сплит[- ]?систем|konditsioner|kansaner|kandisaner|klimat|air\s*con|aer\s+condi[țt]ionat/iu,
138
138
  /без\s+(?:кондицион\p{L}*|сплит[- ]?систем\p{L}*)|нет\s+(?:кондицион\p{L}*|сплит[- ]?систем\p{L}*)|(?:кондицион\p{L}*|сплит[- ]?систем\p{L}*)\s+нет|no\s+air\s*con(?:ditioner)?|konditsioner\s+yo['’]?q|кондиционер\s+йўқ/iu,
139
139
  ),
140
+ // "тв"/"tv" are bare 2-letter abbreviations that collide with real words
141
+ // (e.g. "твой" = "your") unless bounded on both sides by a non-letter --
142
+ // the same class of bug as the "пр" abbreviation fixed earlier in
143
+ // compactStreet(); do not relax these boundaries to a bare \b.
144
+ tv: bool(
145
+ text,
146
+ /телевизор|(?:^|[^\p{L}\p{N}_])(?:тв|tv)(?=$|[^\p{L}\p{N}_])|television|televizor/iu,
147
+ /без\s+телевизор\p{L}*|нет\s+телевизор\p{L}*|телевизор\p{L}*\s+нет|no\s+tv|no\s+television/iu,
148
+ ),
149
+ microwave: bool(
150
+ text,
151
+ /микроволнов\p{L}*|(?:^|[^\p{L}\p{N}_])свч(?=$|[^\p{L}\p{N}_])|microwave|mikro(?:to['’]?lqinli|talqinli)\s*pech/iu,
152
+ /без\s+микроволнов\p{L}*|нет\s+микроволнов\p{L}*|микроволнов\p{L}*\s+нет|no\s+microwave/iu,
153
+ ),
154
+ oven: bool(
155
+ text,
156
+ /духовк\p{L}*|духов\p{L}*\s+шкаф\p{L}*|oven|(?:^|[^\p{L}\p{N}_])pech(?=$|[^\p{L}\p{N}_])/iu,
157
+ /без\s+духовк\p{L}*|нет\s+духовк\p{L}*|духовк\p{L}*\s+нет|no\s+oven/iu,
158
+ ),
159
+ bidet: bool(
160
+ text,
161
+ /биде|bidet/iu,
162
+ /без\s+биде|нет\s+биде|биде\s+нет|no\s+bidet/iu,
163
+ ),
164
+ walkInCloset: bool(
165
+ text,
166
+ /гардеробн\p{L}*|walk[- ]?in\s+closet|dressing\s+room|garderob(?:naya)?/iu,
167
+ /без\s+гардеробн\p{L}*|нет\s+гардеробн\p{L}*|гардеробн\p{L}*\s+нет|no\s+walk[- ]?in\s+closet/iu,
168
+ ),
169
+ // "ванна" (the tub fixture) and "ванная" (the bathroom-as-a-room) share a
170
+ // stem, so this only matches the shorter nominative/accusative tub forms
171
+ // with a hard boundary -- it will miss instrumental/genitive tub mentions
172
+ // ("с ванной") since those are spelled identically to the room noun and
173
+ // aren't safely disambiguable by regex alone.
174
+ bathtub: bool(
175
+ text,
176
+ /(?:^|[^\p{L}\p{N}_])(?:ванна|ванну|vanna(?:si)?)(?=$|[^\p{L}\p{N}_])|bathtub/iu,
177
+ /без\s+ванн\p{L}*|нет\s+ванн\p{L}*|ванн\p{L}*\s+нет|no\s+bathtub/iu,
178
+ ),
179
+ shower: bool(
180
+ text,
181
+ /(?:^|[^\p{L}\p{N}_])душ(?=$|[^\p{L}\p{N}_])|душев\p{L}*\s+кабин\p{L}*|shower|dush(?:kabina)?/iu,
182
+ /без\s+душ\p{L}*|нет\s+душ\p{L}*|душ\p{L}*\s+нет|no\s+shower/iu,
183
+ ),
184
+ // "Euro-layout" (евродвушка/евротрёшка/евро-N/европланировка) is a CIS
185
+ // real-estate term for an open-plan flat with the kitchen merged into the
186
+ // living room, as opposed to a conventional flat of the same room count
187
+ // with a separate closed kitchen. Positive-only: there's no common way
188
+ // listings state the absence of this, only its presence.
189
+ euroLayout: bool(
190
+ text,
191
+ /евро[- ]?(?:студи\p{L}*|двушк\p{L}*|трёшк\p{L}*|трешк\p{L}*|четырёшк\p{L}*|четрешк\p{L}*|планировк\p{L}*|[1-9](?!\p{N}))|европланировк\p{L}*|euro[- ]?layout/iu,
192
+ ),
140
193
  gas,
141
194
  newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s*built|yangi\s+(?:bino|qurilgan|uy)|bloc\s+nou/iu),
142
195
  communalSeparated: parseCommunalSeparated(text, country),
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
  }),