@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 +1 -1
- package/src/geography-display.js +13 -1
- 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/housing-listing-enrichment.d.ts +8 -0
- package/src/housing-listing-enrichment.js +8 -0
- package/src/housing-listing-fields.d.ts +8 -0
- package/src/housing-listing-fields.js +53 -0
- 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/geography-display.js
CHANGED
|
@@ -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
|
|
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;
|
|
@@ -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
|
|
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
|
}),
|