@whiteslove/parsing-lexicon 0.2.0 → 0.2.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/LICENSE +21 -21
- package/README.md +137 -137
- package/index.d.ts +374 -374
- package/package.json +77 -75
- package/src/central-asia-locations.js +211 -211
- package/src/central-asia.js +233 -233
- package/src/contact.d.ts +44 -44
- package/src/contact.js +159 -159
- package/src/countries.js +175 -175
- package/src/country-context.d.ts +10 -10
- package/src/country-context.js +22 -22
- package/src/currency.d.ts +14 -14
- package/src/currency.js +66 -66
- package/src/geo.js +169 -169
- package/src/geography-central-asia.js +42 -42
- package/src/geography-detection.d.ts +14 -14
- package/src/geography-detection.js +64 -64
- package/src/geography-display.d.ts +13 -13
- package/src/geography-display.js +113 -113
- package/src/geography.js +210 -210
- package/src/hiring-advanced.js +147 -147
- package/src/hiring-candidate-fields.d.ts +9 -9
- package/src/hiring-candidate-fields.js +92 -92
- package/src/hiring-context.d.ts +64 -64
- package/src/hiring-context.js +412 -412
- package/src/hiring-language-extensions.d.ts +19 -19
- package/src/hiring-language-extensions.js +139 -139
- package/src/hiring-languages.js +135 -135
- package/src/hiring-location-fields.d.ts +3 -3
- package/src/hiring-location-fields.js +53 -53
- package/src/hiring-professions-ro.js +191 -191
- package/src/hiring-professions.js +437 -437
- package/src/hiring-requirements.d.ts +16 -16
- package/src/hiring-requirements.js +170 -170
- package/src/hiring-salary-context.d.ts +27 -27
- package/src/hiring-salary-context.js +76 -76
- package/src/hiring-semantics.d.ts +29 -29
- package/src/hiring-semantics.js +218 -218
- package/src/hiring-skills.d.ts +21 -21
- package/src/hiring-skills.js +315 -315
- package/src/hiring-source-aliases.d.ts +32 -32
- package/src/hiring-source-aliases.js +185 -185
- package/src/hiring-source-semantics.d.ts +31 -31
- package/src/hiring-source-semantics.js +293 -293
- package/src/hiring-temporal.d.ts +15 -15
- package/src/hiring-temporal.js +67 -67
- package/src/hiring-vacancy-fields.d.ts +5 -5
- package/src/hiring-vacancy-fields.js +23 -23
- package/src/hiring-work-semantics.d.ts +34 -34
- package/src/hiring-work-semantics.js +72 -72
- package/src/hiring.js +180 -180
- package/src/housing-address.d.ts +18 -18
- package/src/housing-address.js +217 -210
- package/src/housing-commercial.d.ts +1 -0
- package/src/housing-commercial.js +9 -0
- package/src/housing-context.d.ts +36 -36
- package/src/housing-context.js +183 -183
- package/src/housing-features.js +30 -30
- package/src/housing-intent.d.ts +9 -9
- package/src/housing-intent.js +132 -132
- package/src/housing-listing-fields.js +131 -131
- package/src/housing-money.d.ts +7 -7
- package/src/housing-money.js +102 -102
- package/src/housing-source-aliases.d.ts +8 -8
- package/src/housing-source-aliases.js +51 -51
- package/src/housing-structured.d.ts +55 -55
- package/src/housing-structured.js +219 -219
- package/src/housing.js +200 -200
- package/src/index.js +45 -44
- package/src/kz-location-extensions.js +307 -307
- package/src/landmarks.js +21 -21
- package/src/lexicon-core.js +58 -58
- package/src/location-merge.js +106 -106
- package/src/locations.js +456 -456
- package/src/money-core.d.ts +10 -10
- package/src/money-core.js +81 -81
- package/src/money-lexicon.d.ts +5 -5
- package/src/money-lexicon.js +79 -79
- package/src/money.js +154 -154
- package/src/normalization.js +384 -384
- package/src/odesa-metropolitan.js +144 -144
- package/src/romania-geography.js +47 -47
- package/src/tashkent-colloquial.js +80 -72
- package/src/tashkent-housing-geography.d.ts +38 -38
- package/src/tashkent-housing-geography.js +211 -211
- package/src/tashkent-pois.js +215 -215
- package/src/tashkent-residential-complexes.js +272 -272
- package/src/ua-location-extensions-major.js +118 -118
- package/src/ua-location-extensions-metro.js +9 -9
- package/src/ua-location-extensions-regional.js +246 -246
- package/src/ua-secondary-cities.d.ts +2 -2
- package/src/ua-secondary-cities.js +57 -57
- package/src/ukraine.js +75 -75
- package/src/uz-location-extensions.js +259 -259
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
export type HiringSeniority = 'intern' | 'junior' | 'middle' | 'senior' | 'staff' | 'lead' | 'principal' | 'manager' | 'director';
|
|
2
|
-
export type DegreeLevel = 'secondary' | 'bachelor' | 'master' | 'doctorate';
|
|
3
|
-
export type DegreeField = 'computer_science' | 'engineering' | 'law' | 'forensics' | 'business';
|
|
4
|
-
export type CvSection = 'experience' | 'projects' | 'profile' | 'skills' | 'education' | 'other';
|
|
5
|
-
export const SENIORITY_RANK: Readonly<Record<HiringSeniority, number>>;
|
|
6
|
-
export function detectHiringSeniority(value: unknown): HiringSeniority | null;
|
|
7
|
-
export function detectDegreeLevel(value: unknown): DegreeLevel | null;
|
|
8
|
-
export function detectDegreeFields(value: unknown): readonly DegreeField[];
|
|
9
|
-
export function extractRequiredExperienceYears(value: unknown): number | null;
|
|
10
|
-
export function hasUsWorkAuthorization(value: unknown): boolean;
|
|
11
|
-
export function requiresUsSponsorship(value: unknown): boolean | null;
|
|
12
|
-
export function isNoSponsorshipRequirement(value: unknown): boolean;
|
|
13
|
-
export function bucketVacancyText(value: unknown): Readonly<{ required: string; optional: string; context: string; noise: string }>;
|
|
14
|
-
export function classifyCvSectionHeading(value: unknown): CvSection | null;
|
|
15
|
-
export function extractCvSection(value: unknown, wanted: CvSection): string;
|
|
16
|
-
export function extractCvExperienceYears(value: unknown, referenceDate?: Date): number | null;
|
|
1
|
+
export type HiringSeniority = 'intern' | 'junior' | 'middle' | 'senior' | 'staff' | 'lead' | 'principal' | 'manager' | 'director';
|
|
2
|
+
export type DegreeLevel = 'secondary' | 'bachelor' | 'master' | 'doctorate';
|
|
3
|
+
export type DegreeField = 'computer_science' | 'engineering' | 'law' | 'forensics' | 'business';
|
|
4
|
+
export type CvSection = 'experience' | 'projects' | 'profile' | 'skills' | 'education' | 'other';
|
|
5
|
+
export const SENIORITY_RANK: Readonly<Record<HiringSeniority, number>>;
|
|
6
|
+
export function detectHiringSeniority(value: unknown): HiringSeniority | null;
|
|
7
|
+
export function detectDegreeLevel(value: unknown): DegreeLevel | null;
|
|
8
|
+
export function detectDegreeFields(value: unknown): readonly DegreeField[];
|
|
9
|
+
export function extractRequiredExperienceYears(value: unknown): number | null;
|
|
10
|
+
export function hasUsWorkAuthorization(value: unknown): boolean;
|
|
11
|
+
export function requiresUsSponsorship(value: unknown): boolean | null;
|
|
12
|
+
export function isNoSponsorshipRequirement(value: unknown): boolean;
|
|
13
|
+
export function bucketVacancyText(value: unknown): Readonly<{ required: string; optional: string; context: string; noise: string }>;
|
|
14
|
+
export function classifyCvSectionHeading(value: unknown): CvSection | null;
|
|
15
|
+
export function extractCvSection(value: unknown, wanted: CvSection): string;
|
|
16
|
+
export function extractCvExperienceYears(value: unknown, referenceDate?: Date): number | null;
|
|
@@ -1,170 +1,170 @@
|
|
|
1
|
-
import { parseExperience } from './hiring-advanced.js';
|
|
2
|
-
|
|
3
|
-
export const SENIORITY_RANK = Object.freeze({
|
|
4
|
-
intern: 0,
|
|
5
|
-
junior: 1,
|
|
6
|
-
middle: 2,
|
|
7
|
-
senior: 3,
|
|
8
|
-
staff: 4,
|
|
9
|
-
lead: 4,
|
|
10
|
-
manager: 4,
|
|
11
|
-
principal: 5,
|
|
12
|
-
director: 6,
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
export function detectHiringSeniority(value) {
|
|
16
|
-
const text = String(value || '');
|
|
17
|
-
const target = /\b(?:looking|searching)\s+for\s+(?:an?\s+)?(?:a\s+)?(intern|junior|middle|mid|senior|staff|principal|lead)\b/i.exec(text)?.[1];
|
|
18
|
-
const normalize = (item) => item.toLowerCase() === 'mid' ? 'middle' : item.toLowerCase();
|
|
19
|
-
if (target) return normalize(target);
|
|
20
|
-
const checks = [
|
|
21
|
-
['director', /\b(?:engineering\s+)?director\b|директор/i],
|
|
22
|
-
['principal', /\bprincipal\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
23
|
-
['staff', /\bstaff\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
24
|
-
['lead', /\b(?:team\s*lead|tech\s*lead|lead\s+(?:engineer|developer|frontend|backend))\b|тимлид|техлид|ведущ\w*/i],
|
|
25
|
-
['manager', /\bengineering manager\b|руководител/i],
|
|
26
|
-
['senior', /\bsenior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|сеньор|старш\w*\s+(?:разработ|инженер)/i],
|
|
27
|
-
['middle', /\b(?:middle|mid[- ]?level)\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|мидл/i],
|
|
28
|
-
['junior', /\bjunior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|джун\w*|младш\w*/i],
|
|
29
|
-
['intern', /\b(?:intern|trainee)\b|стаж[ёе]р|стажир/i],
|
|
30
|
-
];
|
|
31
|
-
return checks.find(([, pattern]) => pattern.test(text))?.[0] || null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function detectDegreeLevel(value) {
|
|
35
|
-
const text = String(value || '');
|
|
36
|
-
if (/\b(?:ph\.?d\.?|doctorate|doctoral degree)\b|доктор(?:ская| наук)/i.test(text)) return 'doctorate';
|
|
37
|
-
if (/master['’]?s degree|master degree|магистр|магістр/i.test(text)) return 'master';
|
|
38
|
-
if (/bachelor['’]?s degree|bachelor degree|бакалавр/i.test(text)) return 'bachelor';
|
|
39
|
-
if (/secondary education|среднее образование|середня освіта/i.test(text)) return 'secondary';
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function detectDegreeFields(value) {
|
|
44
|
-
const text = String(value || '');
|
|
45
|
-
const fields = [];
|
|
46
|
-
if (/computer science|computer engineering|software engineering|information technology|informatics|інформатик|информатик/i.test(text)) fields.push('computer_science');
|
|
47
|
-
if (/\bengineering\b|инженерн|інженерн/i.test(text)) fields.push('engineering');
|
|
48
|
-
if (/\b(?:civil\s+)?law\b|legal studies|юридич|юриспруд|право\b/i.test(text)) fields.push('law');
|
|
49
|
-
if (/forensic|criminal investigation|криминалист|криміналіст|следствен|слідч/i.test(text)) fields.push('forensics');
|
|
50
|
-
if (/business|economics|finance|management|эконом|економ/i.test(text)) fields.push('business');
|
|
51
|
-
return Object.freeze([...new Set(fields)]);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function extractRequiredExperienceYears(value) {
|
|
55
|
-
const parsed = parseExperience(value);
|
|
56
|
-
return typeof parsed?.minYears === 'number' ? parsed.minYears : null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function hasUsWorkAuthorization(value) {
|
|
60
|
-
const text = String(value || '');
|
|
61
|
-
return /\b(?:u\.?s\.?|united states)\s+citizen\b|\bgreen card\b|\bpermanent resident\b|\bemployment authorization document\b|\bEAD\b|\bauthoriz\w+\s+to\s+work\s+in\s+(?:the\s+)?(?:u\.?s\.?|united states)(?:[^.!?]{0,50}\bwithout\s+sponsorship\b)?|\bno\s+(?:visa\s+)?sponsorship\s+required\b/i.test(text);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function requiresUsSponsorship(value) {
|
|
65
|
-
const text = String(value || '');
|
|
66
|
-
if (hasUsWorkAuthorization(text)) return false;
|
|
67
|
-
if (/\b(?:require|requires|requiring|need|needs|seeking)\b[^.!?]{0,60}\b(?:visa|employment)\s+sponsorship\b|\bneed\s+(?:an?\s+)?(?:h-?1b|work visa)\b/i.test(text)) return true;
|
|
68
|
-
const citizenship = /\bcitizenship\s*[:\-]\s*([^\n|,;]{2,45})/i.exec(text)?.[1];
|
|
69
|
-
if (citizenship && !/^\s*(?:u\.?s\.?a?|united states|american)\b/i.test(citizenship)) return true;
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function isNoSponsorshipRequirement(value) {
|
|
74
|
-
const text = String(value || '');
|
|
75
|
-
return /(?:\bno\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\b(?:will\s+not|cannot|can't|unable\s+to|not\s+able\s+to)\s+sponsor\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:current\s+or\s+future\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,100}\bwithout\s+(?:current\s+or\s+future\s+)?sponsorship\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bmay\s+not\s+be\s+able\s+to\b[^\n!?]{0,450}\b(?:sponsor|support|provide)\b[^\n!?]{0,180}\bsponsorship\b|\b(?:will|can|may)\s+not\b[^\n!?]{0,220}\b(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/i.test(text);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const REQUIRED_MARKER_RE = /\b(requirements?|qualifications?|minimum qualifications?|required skills?|must[- ]?have|you have|what (?:we|you) (?:are looking for|need|bring)|you(?:'|’)ll need|who you are|ideal candidate|what makes you a fit)\b|требован|квалификац|обязательн|необходим(?:о|ые|ый)|что мы (?:жд[её]м|ожидаем)|кого мы ищем|вимог|кваліфікац|обов['’]?язков|необхідн|кого ми шукаємо/i;
|
|
79
|
-
const OPTIONAL_MARKER_RE = /\b(nice to have|preferred qualifications?|preferred skills?|bonus points?|would be a plus|plus if|desirable)\b|желательн|будет плюсом|буде плюсом|преимуществ|бажан/i;
|
|
80
|
-
const HARD_REQUIREMENT_RE = /\b(must|need to|required|proficien(?:t|cy)|expertise in|experience (?:with|in)|knowledge of|familiarity with|hands[- ]on)\b|обязател|требуется|необходим|знание|опыт (?:с|в)|владение|умение|потрібн|необхідн|досвід (?:з|у|в)|знання/i;
|
|
81
|
-
const NOISE_RE = /\b(equal opportunity|eeo|diversity and inclusion|reasonable accommodation|candidate privacy|privacy notice|background check|recruit(?:ment|ing) process|talent acquisition team|compensation range|pay transparency)\b|процесс найма|процес найму|политик[аи] конфиденциальности|політик[аи] конфіденційності/i;
|
|
82
|
-
const SECTION_BREAK_RE = /\b(what we offer|benefits|perks|about us|about the company|our company|compensation|salary|responsibilities|what you(?:'|’)ll do|your role)\b|что мы предлагаем|условия работы|о компании|про компанію|обязанности|обов['’]?язки/i;
|
|
83
|
-
|
|
84
|
-
export function bucketVacancyText(value) {
|
|
85
|
-
const segments = String(value || '').replace(/[•●▪◦·]/g, '. ').split(/\n+|(?<=[.!?;])\s+/).map((part) => part.trim()).filter(Boolean);
|
|
86
|
-
const buckets = { required: [], optional: [], context: [], noise: [] };
|
|
87
|
-
let active = null;
|
|
88
|
-
let ttl = 0;
|
|
89
|
-
for (const segment of segments) {
|
|
90
|
-
if (NOISE_RE.test(segment)) { buckets.noise.push(segment); active = null; ttl = 0; continue; }
|
|
91
|
-
buckets.context.push(segment);
|
|
92
|
-
if (OPTIONAL_MARKER_RE.test(segment)) { active = 'optional'; ttl = 6; buckets.optional.push(segment); continue; }
|
|
93
|
-
if (REQUIRED_MARKER_RE.test(segment)) { active = 'required'; ttl = 8; buckets.required.push(segment); continue; }
|
|
94
|
-
if (SECTION_BREAK_RE.test(segment)) { active = null; ttl = 0; }
|
|
95
|
-
if (HARD_REQUIREMENT_RE.test(segment)) { buckets.required.push(segment); continue; }
|
|
96
|
-
if (active && ttl > 0) { buckets[active].push(segment); ttl -= 1; }
|
|
97
|
-
}
|
|
98
|
-
return Object.freeze({ required: buckets.required.join(' '), optional: buckets.optional.join(' '), context: buckets.context.join(' '), noise: buckets.noise.join(' ') });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const CV_SECTION_HEADING_RE = /^\s*(profile|professional profile|summary|professional summary|about me|work experience|professional experience|experience|employment|employment history|projects?|pet projects?|hobbies|skills|technical skills|tech stack|education|languages?|contact|additional information)\s*:?[\s]*$/i;
|
|
102
|
-
|
|
103
|
-
export function classifyCvSectionHeading(value) {
|
|
104
|
-
const heading = CV_SECTION_HEADING_RE.exec(String(value || '').trim())?.[1];
|
|
105
|
-
if (!heading) return null;
|
|
106
|
-
const normalized = heading.toLowerCase();
|
|
107
|
-
if (/work experience|professional experience|^experience$|employment/.test(normalized)) return 'experience';
|
|
108
|
-
if (/project|hobbies/.test(normalized)) return 'projects';
|
|
109
|
-
if (/profile|summary|about me/.test(normalized)) return 'profile';
|
|
110
|
-
if (/skills|tech stack/.test(normalized)) return 'skills';
|
|
111
|
-
if (/education/.test(normalized)) return 'education';
|
|
112
|
-
return 'other';
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function extractCvSection(value, wanted) {
|
|
116
|
-
const lines = String(value || '').replace(/\r/g, '').split('\n');
|
|
117
|
-
const collected = [];
|
|
118
|
-
let section = 'other';
|
|
119
|
-
let sawHeading = false;
|
|
120
|
-
for (const line of lines) {
|
|
121
|
-
const trimmed = line.trim();
|
|
122
|
-
const nextSection = classifyCvSectionHeading(trimmed);
|
|
123
|
-
if (nextSection) {
|
|
124
|
-
section = nextSection;
|
|
125
|
-
sawHeading = true;
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (section === wanted && trimmed) collected.push(trimmed);
|
|
129
|
-
}
|
|
130
|
-
return sawHeading ? collected.join('\n') : '';
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function monthIndex(year, month = 1) {
|
|
134
|
-
return year * 12 + Math.max(1, Math.min(12, month)) - 1;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function extractCvExperienceYears(value, referenceDate = new Date()) {
|
|
138
|
-
const raw = String(value || '');
|
|
139
|
-
const experienceSection = extractCvSection(raw, 'experience');
|
|
140
|
-
const datedSource = experienceSection || raw;
|
|
141
|
-
const intervals = [];
|
|
142
|
-
const ranges = /\b(19\d{2}|20\d{2})(?:[-/.](0?[1-9]|1[0-2]))?\s*(?:-|–|—|to)\s*(?:(present|current|now)|((?:19|20)\d{2})(?:[-/.](0?[1-9]|1[0-2]))?)/gi;
|
|
143
|
-
for (const match of datedSource.matchAll(ranges)) {
|
|
144
|
-
const startYear = Number(match[1]);
|
|
145
|
-
const startMonth = Number(match[2] || 1);
|
|
146
|
-
const endYear = match[3] ? referenceDate.getFullYear() : Number(match[4]);
|
|
147
|
-
const endMonth = match[3] ? referenceDate.getMonth() + 1 : Number(match[5] || 12);
|
|
148
|
-
if (!startYear || !endYear) continue;
|
|
149
|
-
const start = monthIndex(startYear, startMonth);
|
|
150
|
-
const end = monthIndex(endYear, endMonth);
|
|
151
|
-
if (end >= start && end - start <= 12 * 50) intervals.push([start, end]);
|
|
152
|
-
}
|
|
153
|
-
intervals.sort((a, b) => a[0] - b[0]);
|
|
154
|
-
const merged = [];
|
|
155
|
-
for (const interval of intervals) {
|
|
156
|
-
const last = merged[merged.length - 1];
|
|
157
|
-
if (!last || interval[0] > last[1] + 1) merged.push([...interval]);
|
|
158
|
-
else last[1] = Math.max(last[1], interval[1]);
|
|
159
|
-
}
|
|
160
|
-
const datedMonths = merged.reduce((sum, [start, end]) => sum + end - start + 1, 0);
|
|
161
|
-
const datedYears = datedMonths ? datedMonths / 12 : 0;
|
|
162
|
-
let explicitYears = 0;
|
|
163
|
-
const explicit = /\b(?:over|more than|at least|about|approximately|approx\.?|around)?\s*(\d{1,2}(?:[.,]\d)?)\+?\s*(?:years?|yrs?)\s+(?:of\s+)?(?:hands[- ]on\s+|professional\s+|commercial\s+)?experience\b/gi;
|
|
164
|
-
for (const match of raw.matchAll(explicit)) {
|
|
165
|
-
const years = Number(match[1]?.replace(',', '.'));
|
|
166
|
-
if (Number.isFinite(years) && years >= 0 && years <= 50) explicitYears = Math.max(explicitYears, years);
|
|
167
|
-
}
|
|
168
|
-
const result = Math.max(datedYears, explicitYears);
|
|
169
|
-
return result > 0 ? Math.round(result * 10) / 10 : null;
|
|
170
|
-
}
|
|
1
|
+
import { parseExperience } from './hiring-advanced.js';
|
|
2
|
+
|
|
3
|
+
export const SENIORITY_RANK = Object.freeze({
|
|
4
|
+
intern: 0,
|
|
5
|
+
junior: 1,
|
|
6
|
+
middle: 2,
|
|
7
|
+
senior: 3,
|
|
8
|
+
staff: 4,
|
|
9
|
+
lead: 4,
|
|
10
|
+
manager: 4,
|
|
11
|
+
principal: 5,
|
|
12
|
+
director: 6,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function detectHiringSeniority(value) {
|
|
16
|
+
const text = String(value || '');
|
|
17
|
+
const target = /\b(?:looking|searching)\s+for\s+(?:an?\s+)?(?:a\s+)?(intern|junior|middle|mid|senior|staff|principal|lead)\b/i.exec(text)?.[1];
|
|
18
|
+
const normalize = (item) => item.toLowerCase() === 'mid' ? 'middle' : item.toLowerCase();
|
|
19
|
+
if (target) return normalize(target);
|
|
20
|
+
const checks = [
|
|
21
|
+
['director', /\b(?:engineering\s+)?director\b|директор/i],
|
|
22
|
+
['principal', /\bprincipal\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
23
|
+
['staff', /\bstaff\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
24
|
+
['lead', /\b(?:team\s*lead|tech\s*lead|lead\s+(?:engineer|developer|frontend|backend))\b|тимлид|техлид|ведущ\w*/i],
|
|
25
|
+
['manager', /\bengineering manager\b|руководител/i],
|
|
26
|
+
['senior', /\bsenior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|сеньор|старш\w*\s+(?:разработ|инженер)/i],
|
|
27
|
+
['middle', /\b(?:middle|mid[- ]?level)\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|мидл/i],
|
|
28
|
+
['junior', /\bjunior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|джун\w*|младш\w*/i],
|
|
29
|
+
['intern', /\b(?:intern|trainee)\b|стаж[ёе]р|стажир/i],
|
|
30
|
+
];
|
|
31
|
+
return checks.find(([, pattern]) => pattern.test(text))?.[0] || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detectDegreeLevel(value) {
|
|
35
|
+
const text = String(value || '');
|
|
36
|
+
if (/\b(?:ph\.?d\.?|doctorate|doctoral degree)\b|доктор(?:ская| наук)/i.test(text)) return 'doctorate';
|
|
37
|
+
if (/master['’]?s degree|master degree|магистр|магістр/i.test(text)) return 'master';
|
|
38
|
+
if (/bachelor['’]?s degree|bachelor degree|бакалавр/i.test(text)) return 'bachelor';
|
|
39
|
+
if (/secondary education|среднее образование|середня освіта/i.test(text)) return 'secondary';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectDegreeFields(value) {
|
|
44
|
+
const text = String(value || '');
|
|
45
|
+
const fields = [];
|
|
46
|
+
if (/computer science|computer engineering|software engineering|information technology|informatics|інформатик|информатик/i.test(text)) fields.push('computer_science');
|
|
47
|
+
if (/\bengineering\b|инженерн|інженерн/i.test(text)) fields.push('engineering');
|
|
48
|
+
if (/\b(?:civil\s+)?law\b|legal studies|юридич|юриспруд|право\b/i.test(text)) fields.push('law');
|
|
49
|
+
if (/forensic|criminal investigation|криминалист|криміналіст|следствен|слідч/i.test(text)) fields.push('forensics');
|
|
50
|
+
if (/business|economics|finance|management|эконом|економ/i.test(text)) fields.push('business');
|
|
51
|
+
return Object.freeze([...new Set(fields)]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractRequiredExperienceYears(value) {
|
|
55
|
+
const parsed = parseExperience(value);
|
|
56
|
+
return typeof parsed?.minYears === 'number' ? parsed.minYears : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hasUsWorkAuthorization(value) {
|
|
60
|
+
const text = String(value || '');
|
|
61
|
+
return /\b(?:u\.?s\.?|united states)\s+citizen\b|\bgreen card\b|\bpermanent resident\b|\bemployment authorization document\b|\bEAD\b|\bauthoriz\w+\s+to\s+work\s+in\s+(?:the\s+)?(?:u\.?s\.?|united states)(?:[^.!?]{0,50}\bwithout\s+sponsorship\b)?|\bno\s+(?:visa\s+)?sponsorship\s+required\b/i.test(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function requiresUsSponsorship(value) {
|
|
65
|
+
const text = String(value || '');
|
|
66
|
+
if (hasUsWorkAuthorization(text)) return false;
|
|
67
|
+
if (/\b(?:require|requires|requiring|need|needs|seeking)\b[^.!?]{0,60}\b(?:visa|employment)\s+sponsorship\b|\bneed\s+(?:an?\s+)?(?:h-?1b|work visa)\b/i.test(text)) return true;
|
|
68
|
+
const citizenship = /\bcitizenship\s*[:\-]\s*([^\n|,;]{2,45})/i.exec(text)?.[1];
|
|
69
|
+
if (citizenship && !/^\s*(?:u\.?s\.?a?|united states|american)\b/i.test(citizenship)) return true;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isNoSponsorshipRequirement(value) {
|
|
74
|
+
const text = String(value || '');
|
|
75
|
+
return /(?:\bno\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\b(?:will\s+not|cannot|can't|unable\s+to|not\s+able\s+to)\s+sponsor\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:current\s+or\s+future\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,100}\bwithout\s+(?:current\s+or\s+future\s+)?sponsorship\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bmay\s+not\s+be\s+able\s+to\b[^\n!?]{0,450}\b(?:sponsor|support|provide)\b[^\n!?]{0,180}\bsponsorship\b|\b(?:will|can|may)\s+not\b[^\n!?]{0,220}\b(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/i.test(text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const REQUIRED_MARKER_RE = /\b(requirements?|qualifications?|minimum qualifications?|required skills?|must[- ]?have|you have|what (?:we|you) (?:are looking for|need|bring)|you(?:'|’)ll need|who you are|ideal candidate|what makes you a fit)\b|требован|квалификац|обязательн|необходим(?:о|ые|ый)|что мы (?:жд[её]м|ожидаем)|кого мы ищем|вимог|кваліфікац|обов['’]?язков|необхідн|кого ми шукаємо/i;
|
|
79
|
+
const OPTIONAL_MARKER_RE = /\b(nice to have|preferred qualifications?|preferred skills?|bonus points?|would be a plus|plus if|desirable)\b|желательн|будет плюсом|буде плюсом|преимуществ|бажан/i;
|
|
80
|
+
const HARD_REQUIREMENT_RE = /\b(must|need to|required|proficien(?:t|cy)|expertise in|experience (?:with|in)|knowledge of|familiarity with|hands[- ]on)\b|обязател|требуется|необходим|знание|опыт (?:с|в)|владение|умение|потрібн|необхідн|досвід (?:з|у|в)|знання/i;
|
|
81
|
+
const NOISE_RE = /\b(equal opportunity|eeo|diversity and inclusion|reasonable accommodation|candidate privacy|privacy notice|background check|recruit(?:ment|ing) process|talent acquisition team|compensation range|pay transparency)\b|процесс найма|процес найму|политик[аи] конфиденциальности|політик[аи] конфіденційності/i;
|
|
82
|
+
const SECTION_BREAK_RE = /\b(what we offer|benefits|perks|about us|about the company|our company|compensation|salary|responsibilities|what you(?:'|’)ll do|your role)\b|что мы предлагаем|условия работы|о компании|про компанію|обязанности|обов['’]?язки/i;
|
|
83
|
+
|
|
84
|
+
export function bucketVacancyText(value) {
|
|
85
|
+
const segments = String(value || '').replace(/[•●▪◦·]/g, '. ').split(/\n+|(?<=[.!?;])\s+/).map((part) => part.trim()).filter(Boolean);
|
|
86
|
+
const buckets = { required: [], optional: [], context: [], noise: [] };
|
|
87
|
+
let active = null;
|
|
88
|
+
let ttl = 0;
|
|
89
|
+
for (const segment of segments) {
|
|
90
|
+
if (NOISE_RE.test(segment)) { buckets.noise.push(segment); active = null; ttl = 0; continue; }
|
|
91
|
+
buckets.context.push(segment);
|
|
92
|
+
if (OPTIONAL_MARKER_RE.test(segment)) { active = 'optional'; ttl = 6; buckets.optional.push(segment); continue; }
|
|
93
|
+
if (REQUIRED_MARKER_RE.test(segment)) { active = 'required'; ttl = 8; buckets.required.push(segment); continue; }
|
|
94
|
+
if (SECTION_BREAK_RE.test(segment)) { active = null; ttl = 0; }
|
|
95
|
+
if (HARD_REQUIREMENT_RE.test(segment)) { buckets.required.push(segment); continue; }
|
|
96
|
+
if (active && ttl > 0) { buckets[active].push(segment); ttl -= 1; }
|
|
97
|
+
}
|
|
98
|
+
return Object.freeze({ required: buckets.required.join(' '), optional: buckets.optional.join(' '), context: buckets.context.join(' '), noise: buckets.noise.join(' ') });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const CV_SECTION_HEADING_RE = /^\s*(profile|professional profile|summary|professional summary|about me|work experience|professional experience|experience|employment|employment history|projects?|pet projects?|hobbies|skills|technical skills|tech stack|education|languages?|contact|additional information)\s*:?[\s]*$/i;
|
|
102
|
+
|
|
103
|
+
export function classifyCvSectionHeading(value) {
|
|
104
|
+
const heading = CV_SECTION_HEADING_RE.exec(String(value || '').trim())?.[1];
|
|
105
|
+
if (!heading) return null;
|
|
106
|
+
const normalized = heading.toLowerCase();
|
|
107
|
+
if (/work experience|professional experience|^experience$|employment/.test(normalized)) return 'experience';
|
|
108
|
+
if (/project|hobbies/.test(normalized)) return 'projects';
|
|
109
|
+
if (/profile|summary|about me/.test(normalized)) return 'profile';
|
|
110
|
+
if (/skills|tech stack/.test(normalized)) return 'skills';
|
|
111
|
+
if (/education/.test(normalized)) return 'education';
|
|
112
|
+
return 'other';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function extractCvSection(value, wanted) {
|
|
116
|
+
const lines = String(value || '').replace(/\r/g, '').split('\n');
|
|
117
|
+
const collected = [];
|
|
118
|
+
let section = 'other';
|
|
119
|
+
let sawHeading = false;
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const trimmed = line.trim();
|
|
122
|
+
const nextSection = classifyCvSectionHeading(trimmed);
|
|
123
|
+
if (nextSection) {
|
|
124
|
+
section = nextSection;
|
|
125
|
+
sawHeading = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (section === wanted && trimmed) collected.push(trimmed);
|
|
129
|
+
}
|
|
130
|
+
return sawHeading ? collected.join('\n') : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function monthIndex(year, month = 1) {
|
|
134
|
+
return year * 12 + Math.max(1, Math.min(12, month)) - 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function extractCvExperienceYears(value, referenceDate = new Date()) {
|
|
138
|
+
const raw = String(value || '');
|
|
139
|
+
const experienceSection = extractCvSection(raw, 'experience');
|
|
140
|
+
const datedSource = experienceSection || raw;
|
|
141
|
+
const intervals = [];
|
|
142
|
+
const ranges = /\b(19\d{2}|20\d{2})(?:[-/.](0?[1-9]|1[0-2]))?\s*(?:-|–|—|to)\s*(?:(present|current|now)|((?:19|20)\d{2})(?:[-/.](0?[1-9]|1[0-2]))?)/gi;
|
|
143
|
+
for (const match of datedSource.matchAll(ranges)) {
|
|
144
|
+
const startYear = Number(match[1]);
|
|
145
|
+
const startMonth = Number(match[2] || 1);
|
|
146
|
+
const endYear = match[3] ? referenceDate.getFullYear() : Number(match[4]);
|
|
147
|
+
const endMonth = match[3] ? referenceDate.getMonth() + 1 : Number(match[5] || 12);
|
|
148
|
+
if (!startYear || !endYear) continue;
|
|
149
|
+
const start = monthIndex(startYear, startMonth);
|
|
150
|
+
const end = monthIndex(endYear, endMonth);
|
|
151
|
+
if (end >= start && end - start <= 12 * 50) intervals.push([start, end]);
|
|
152
|
+
}
|
|
153
|
+
intervals.sort((a, b) => a[0] - b[0]);
|
|
154
|
+
const merged = [];
|
|
155
|
+
for (const interval of intervals) {
|
|
156
|
+
const last = merged[merged.length - 1];
|
|
157
|
+
if (!last || interval[0] > last[1] + 1) merged.push([...interval]);
|
|
158
|
+
else last[1] = Math.max(last[1], interval[1]);
|
|
159
|
+
}
|
|
160
|
+
const datedMonths = merged.reduce((sum, [start, end]) => sum + end - start + 1, 0);
|
|
161
|
+
const datedYears = datedMonths ? datedMonths / 12 : 0;
|
|
162
|
+
let explicitYears = 0;
|
|
163
|
+
const explicit = /\b(?:over|more than|at least|about|approximately|approx\.?|around)?\s*(\d{1,2}(?:[.,]\d)?)\+?\s*(?:years?|yrs?)\s+(?:of\s+)?(?:hands[- ]on\s+|professional\s+|commercial\s+)?experience\b/gi;
|
|
164
|
+
for (const match of raw.matchAll(explicit)) {
|
|
165
|
+
const years = Number(match[1]?.replace(',', '.'));
|
|
166
|
+
if (Number.isFinite(years) && years >= 0 && years <= 50) explicitYears = Math.max(explicitYears, years);
|
|
167
|
+
}
|
|
168
|
+
const result = Math.max(datedYears, explicitYears);
|
|
169
|
+
return result > 0 ? Math.round(result * 10) / 10 : null;
|
|
170
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
export type SalaryCurrencySource = 'explicit' | 'country-default' | 'language-default' | 'unknown';
|
|
2
|
-
|
|
3
|
-
export interface HiringSalaryContextOptions {
|
|
4
|
-
country?: string | null;
|
|
5
|
-
location?: string | null;
|
|
6
|
-
currencyFallback?: 'country' | 'language';
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface ParsedHiringSalaryWithContext {
|
|
10
|
-
min: number | null;
|
|
11
|
-
max: number | null;
|
|
12
|
-
currency: string | null;
|
|
13
|
-
period: string | null;
|
|
14
|
-
negotiable: boolean;
|
|
15
|
-
gross: boolean | null;
|
|
16
|
-
approximate: boolean;
|
|
17
|
-
currencySource: SalaryCurrencySource;
|
|
18
|
-
currencyCountry: string | null;
|
|
19
|
-
[key: string]: unknown;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const COUNTRY_DEFAULT_CURRENCIES: Readonly<Record<string, string>>;
|
|
23
|
-
export function defaultCurrencyForCountry(value: unknown): string | null;
|
|
24
|
-
export function parseHiringSalaryWithContext(
|
|
25
|
-
value: unknown,
|
|
26
|
-
options?: HiringSalaryContextOptions,
|
|
27
|
-
): ParsedHiringSalaryWithContext | null;
|
|
1
|
+
export type SalaryCurrencySource = 'explicit' | 'country-default' | 'language-default' | 'unknown';
|
|
2
|
+
|
|
3
|
+
export interface HiringSalaryContextOptions {
|
|
4
|
+
country?: string | null;
|
|
5
|
+
location?: string | null;
|
|
6
|
+
currencyFallback?: 'country' | 'language';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParsedHiringSalaryWithContext {
|
|
10
|
+
min: number | null;
|
|
11
|
+
max: number | null;
|
|
12
|
+
currency: string | null;
|
|
13
|
+
period: string | null;
|
|
14
|
+
negotiable: boolean;
|
|
15
|
+
gross: boolean | null;
|
|
16
|
+
approximate: boolean;
|
|
17
|
+
currencySource: SalaryCurrencySource;
|
|
18
|
+
currencyCountry: string | null;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const COUNTRY_DEFAULT_CURRENCIES: Readonly<Record<string, string>>;
|
|
23
|
+
export function defaultCurrencyForCountry(value: unknown): string | null;
|
|
24
|
+
export function parseHiringSalaryWithContext(
|
|
25
|
+
value: unknown,
|
|
26
|
+
options?: HiringSalaryContextOptions,
|
|
27
|
+
): ParsedHiringSalaryWithContext | null;
|
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
import { detectCountryCodeFromText } from './geography-detection.js';
|
|
2
|
-
import { parseSalary } from './money.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Default local currencies used only as an opt-in fallback when salary text
|
|
6
|
-
* contains an amount but no explicit currency. Explicit currency always wins.
|
|
7
|
-
*/
|
|
8
|
-
export const COUNTRY_DEFAULT_CURRENCIES = Object.freeze({
|
|
9
|
-
US: 'USD',
|
|
10
|
-
GB: 'GBP',
|
|
11
|
-
UA: 'UAH',
|
|
12
|
-
UZ: 'UZS',
|
|
13
|
-
KZ: 'KZT',
|
|
14
|
-
KG: 'KGS',
|
|
15
|
-
RO: 'RON',
|
|
16
|
-
PL: 'PLN',
|
|
17
|
-
TR: 'TRY',
|
|
18
|
-
GE: 'GEL',
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
export function defaultCurrencyForCountry(value) {
|
|
22
|
-
const country = detectCountryCodeFromText(value);
|
|
23
|
-
return country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function contextCountry(options) {
|
|
27
|
-
return detectCountryCodeFromText(options.country)
|
|
28
|
-
|| detectCountryCodeFromText(options.location)
|
|
29
|
-
|| null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function languageDefaultCountry(value) {
|
|
33
|
-
const text = String(value || '');
|
|
34
|
-
// Use only language signals that are specific enough to avoid turning generic
|
|
35
|
-
// Russian/English salary text into a local currency guess. Uzbek and Kazakh
|
|
36
|
-
// salary/period vocabulary is deterministic enough for this fallback.
|
|
37
|
-
if (/(?:^|[^\p{L}\p{N}])(?:oylik|oyiga|maosh|ish\s+haqi|ойлик|ойига|маош|иш\s+ҳақи)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'UZ';
|
|
38
|
-
if (/(?:^|[^\p{L}\p{N}])(?:жалақы|еңбекақы|айлық|айына)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'KZ';
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Parse salary text and optionally infer a missing currency from geography or
|
|
44
|
-
* unambiguous local salary-language markers.
|
|
45
|
-
*
|
|
46
|
-
* The fallback is deliberately opt-in. A bare number must never silently become
|
|
47
|
-
* USD merely because a consumer happens to normalize all salaries to USD later.
|
|
48
|
-
* Provenance is returned in `currencySource` so consumers can distinguish an
|
|
49
|
-
* explicit currency from a contextual fallback.
|
|
50
|
-
*/
|
|
51
|
-
export function parseHiringSalaryWithContext(value, options = {}) {
|
|
52
|
-
const parsed = parseSalary(value);
|
|
53
|
-
if (!parsed) return null;
|
|
54
|
-
|
|
55
|
-
if (parsed.currency) {
|
|
56
|
-
return Object.freeze({
|
|
57
|
-
...parsed,
|
|
58
|
-
currencySource: 'explicit',
|
|
59
|
-
currencyCountry: null,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
let country = null;
|
|
64
|
-
if (options.currencyFallback === 'country') country = contextCountry(options);
|
|
65
|
-
else if (options.currencyFallback === 'language') country = languageDefaultCountry(value);
|
|
66
|
-
|
|
67
|
-
const currency = country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
|
|
68
|
-
return Object.freeze({
|
|
69
|
-
...parsed,
|
|
70
|
-
currency,
|
|
71
|
-
currencySource: currency
|
|
72
|
-
? options.currencyFallback === 'language' ? 'language-default' : 'country-default'
|
|
73
|
-
: 'unknown',
|
|
74
|
-
currencyCountry: currency ? country : null,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
1
|
+
import { detectCountryCodeFromText } from './geography-detection.js';
|
|
2
|
+
import { parseSalary } from './money.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default local currencies used only as an opt-in fallback when salary text
|
|
6
|
+
* contains an amount but no explicit currency. Explicit currency always wins.
|
|
7
|
+
*/
|
|
8
|
+
export const COUNTRY_DEFAULT_CURRENCIES = Object.freeze({
|
|
9
|
+
US: 'USD',
|
|
10
|
+
GB: 'GBP',
|
|
11
|
+
UA: 'UAH',
|
|
12
|
+
UZ: 'UZS',
|
|
13
|
+
KZ: 'KZT',
|
|
14
|
+
KG: 'KGS',
|
|
15
|
+
RO: 'RON',
|
|
16
|
+
PL: 'PLN',
|
|
17
|
+
TR: 'TRY',
|
|
18
|
+
GE: 'GEL',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function defaultCurrencyForCountry(value) {
|
|
22
|
+
const country = detectCountryCodeFromText(value);
|
|
23
|
+
return country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function contextCountry(options) {
|
|
27
|
+
return detectCountryCodeFromText(options.country)
|
|
28
|
+
|| detectCountryCodeFromText(options.location)
|
|
29
|
+
|| null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function languageDefaultCountry(value) {
|
|
33
|
+
const text = String(value || '');
|
|
34
|
+
// Use only language signals that are specific enough to avoid turning generic
|
|
35
|
+
// Russian/English salary text into a local currency guess. Uzbek and Kazakh
|
|
36
|
+
// salary/period vocabulary is deterministic enough for this fallback.
|
|
37
|
+
if (/(?:^|[^\p{L}\p{N}])(?:oylik|oyiga|maosh|ish\s+haqi|ойлик|ойига|маош|иш\s+ҳақи)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'UZ';
|
|
38
|
+
if (/(?:^|[^\p{L}\p{N}])(?:жалақы|еңбекақы|айлық|айына)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'KZ';
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse salary text and optionally infer a missing currency from geography or
|
|
44
|
+
* unambiguous local salary-language markers.
|
|
45
|
+
*
|
|
46
|
+
* The fallback is deliberately opt-in. A bare number must never silently become
|
|
47
|
+
* USD merely because a consumer happens to normalize all salaries to USD later.
|
|
48
|
+
* Provenance is returned in `currencySource` so consumers can distinguish an
|
|
49
|
+
* explicit currency from a contextual fallback.
|
|
50
|
+
*/
|
|
51
|
+
export function parseHiringSalaryWithContext(value, options = {}) {
|
|
52
|
+
const parsed = parseSalary(value);
|
|
53
|
+
if (!parsed) return null;
|
|
54
|
+
|
|
55
|
+
if (parsed.currency) {
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
...parsed,
|
|
58
|
+
currencySource: 'explicit',
|
|
59
|
+
currencyCountry: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let country = null;
|
|
64
|
+
if (options.currencyFallback === 'country') country = contextCountry(options);
|
|
65
|
+
else if (options.currencyFallback === 'language') country = languageDefaultCountry(value);
|
|
66
|
+
|
|
67
|
+
const currency = country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
...parsed,
|
|
70
|
+
currency,
|
|
71
|
+
currencySource: currency
|
|
72
|
+
? options.currencyFallback === 'language' ? 'language-default' : 'country-default'
|
|
73
|
+
: 'unknown',
|
|
74
|
+
currencyCountry: currency ? country : null,
|
|
75
|
+
});
|
|
76
|
+
}
|