@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 +1 -1
- package/src/geo.js +1 -1
- package/src/geography-display.d.ts +28 -2
- package/src/geography-display.js +122 -2
- package/src/hiring-salary-context.d.ts +6 -0
- package/src/hiring-salary-context.js +57 -23
- package/src/hiring-vacancy-fields.d.ts +6 -0
- package/src/hiring-vacancy-fields.js +43 -0
- package/src/hiring-work-semantics.js +16 -2
- package/src/locations.js +253 -33
- package/src/money-core.js +4 -1
- package/src/money.js +27 -6
- package/src/uz-location-extensions.js +11 -2
package/package.json
CHANGED
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('
|
|
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 =
|
|
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(
|
|
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;
|
package/src/geography-display.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
62
|
+
function withContext(parsed, value, options) {
|
|
43
63
|
if (!parsed) return null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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
|
|
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}
|
|
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 (!
|
|
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
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
}),
|