@whiteslove/parsing-lexicon 0.2.0
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 -0
- package/README.md +137 -0
- package/index.d.ts +375 -0
- package/package.json +75 -0
- package/src/central-asia-locations.js +211 -0
- package/src/central-asia.js +233 -0
- package/src/contact.d.ts +44 -0
- package/src/contact.js +159 -0
- package/src/countries.js +175 -0
- package/src/country-context.d.ts +10 -0
- package/src/country-context.js +22 -0
- package/src/currency.d.ts +14 -0
- package/src/currency.js +66 -0
- package/src/geo.js +169 -0
- package/src/geography-central-asia.js +42 -0
- package/src/geography-detection.d.ts +14 -0
- package/src/geography-detection.js +64 -0
- package/src/geography-display.d.ts +13 -0
- package/src/geography-display.js +113 -0
- package/src/geography.js +210 -0
- package/src/hiring-advanced.js +147 -0
- package/src/hiring-candidate-fields.d.ts +9 -0
- package/src/hiring-candidate-fields.js +92 -0
- package/src/hiring-context.d.ts +64 -0
- package/src/hiring-context.js +412 -0
- package/src/hiring-language-extensions.d.ts +19 -0
- package/src/hiring-language-extensions.js +139 -0
- package/src/hiring-languages.js +135 -0
- package/src/hiring-location-fields.d.ts +3 -0
- package/src/hiring-location-fields.js +53 -0
- package/src/hiring-professions-ro.js +191 -0
- package/src/hiring-professions.js +437 -0
- package/src/hiring-requirements.d.ts +16 -0
- package/src/hiring-requirements.js +170 -0
- package/src/hiring-salary-context.d.ts +27 -0
- package/src/hiring-salary-context.js +76 -0
- package/src/hiring-semantics.d.ts +29 -0
- package/src/hiring-semantics.js +218 -0
- package/src/hiring-skills.d.ts +21 -0
- package/src/hiring-skills.js +315 -0
- package/src/hiring-source-aliases.d.ts +32 -0
- package/src/hiring-source-aliases.js +186 -0
- package/src/hiring-source-semantics.d.ts +31 -0
- package/src/hiring-source-semantics.js +293 -0
- package/src/hiring-temporal.d.ts +15 -0
- package/src/hiring-temporal.js +67 -0
- package/src/hiring-vacancy-fields.d.ts +5 -0
- package/src/hiring-vacancy-fields.js +23 -0
- package/src/hiring-work-semantics.d.ts +34 -0
- package/src/hiring-work-semantics.js +72 -0
- package/src/hiring.js +180 -0
- package/src/housing-address.d.ts +18 -0
- package/src/housing-address.js +210 -0
- package/src/housing-context.d.ts +36 -0
- package/src/housing-context.js +183 -0
- package/src/housing-features.js +30 -0
- package/src/housing-intent.d.ts +9 -0
- package/src/housing-intent.js +132 -0
- package/src/housing-listing-fields.js +131 -0
- package/src/housing-money.d.ts +7 -0
- package/src/housing-money.js +102 -0
- package/src/housing-source-aliases.d.ts +8 -0
- package/src/housing-source-aliases.js +51 -0
- package/src/housing-structured.d.ts +55 -0
- package/src/housing-structured.js +219 -0
- package/src/housing.js +200 -0
- package/src/index.js +44 -0
- package/src/kz-location-extensions.js +307 -0
- package/src/landmarks.js +21 -0
- package/src/lexicon-core.js +58 -0
- package/src/location-merge.js +106 -0
- package/src/locations.js +456 -0
- package/src/money-core.d.ts +10 -0
- package/src/money-core.js +81 -0
- package/src/money-lexicon.d.ts +5 -0
- package/src/money-lexicon.js +79 -0
- package/src/money.js +154 -0
- package/src/normalization.js +384 -0
- package/src/odesa-metropolitan.js +145 -0
- package/src/romania-geography.js +47 -0
- package/src/tashkent-colloquial.js +72 -0
- package/src/tashkent-housing-geography.d.ts +38 -0
- package/src/tashkent-housing-geography.js +211 -0
- package/src/tashkent-pois.js +215 -0
- package/src/tashkent-residential-complexes.js +272 -0
- package/src/ua-location-extensions-major.js +118 -0
- package/src/ua-location-extensions-metro.js +9 -0
- package/src/ua-location-extensions-regional.js +246 -0
- package/src/ua-secondary-cities.d.ts +2 -0
- package/src/ua-secondary-cities.js +57 -0
- package/src/ukraine.js +75 -0
- package/src/uz-location-extensions.js +259 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { CANDIDATE_FIELD_TERMS, JOB_FIELD_TERMS } from './hiring.js';
|
|
2
|
+
import { aliasesOf, escapeRegex, normalizeUnicode } from './normalization.js';
|
|
3
|
+
import { parseSalary } from './money.js';
|
|
4
|
+
import { extractCandidateName } from './hiring-candidate-fields.js';
|
|
5
|
+
import { countryCurrency } from './country-context.js';
|
|
6
|
+
|
|
7
|
+
const FIELD_EXTRA_ALIASES = Object.freeze({
|
|
8
|
+
candidate: Object.freeze({
|
|
9
|
+
name: ['xodim', 'hodim', 'nomzod', 'candidate', 'f.i.o.', 'f i sh'],
|
|
10
|
+
profession: ["so'ralgan ish joyi", "so'ralgan ish turi", 'qidirayotgan kasb'],
|
|
11
|
+
skills: ['texnologiya', 'stack'],
|
|
12
|
+
salary: ['narx', 'narxi'],
|
|
13
|
+
address: ['hozirgi manzil', 'yashash joyi'],
|
|
14
|
+
contact: ['murojaat', 'boglanish'],
|
|
15
|
+
employmentType: ['format', 'bandlik', 'ish vaqti'],
|
|
16
|
+
}),
|
|
17
|
+
job: Object.freeze({
|
|
18
|
+
title: ['vacancy', 'position', 'role', 'job', 'вакансия', 'позиция', 'посада', 'loc de muncă', 'loc de munca', 'post', 'angajare'],
|
|
19
|
+
company: ['company', 'employer', 'компания', 'работодатель', 'роботодавець', 'компанія', 'companie', 'angajator', 'tashkilot', 'ish beruvchi'],
|
|
20
|
+
location: ['location', 'city', 'локация', 'локація', 'город', 'місто', 'locație', 'locatie', 'oraș', 'oras', 'manzil', 'shahar'],
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function entryAliases(entry, extras = []) {
|
|
25
|
+
return [...new Set([entry?.canonical, ...aliasesOf(entry || {}), ...extras].filter(Boolean))];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fieldAliasPattern(value) {
|
|
29
|
+
return escapeRegex(normalizeUnicode(value).trim())
|
|
30
|
+
.replace(/[\s\-–—'’‘`ʻʼ]+/g, "[\\s\\-–—'’‘`ʻʼ]*");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractField(value, entry, extras = [], maxLength = 220) {
|
|
34
|
+
const text = String(value || '');
|
|
35
|
+
const aliases = entryAliases(entry, extras).sort((a, b) => b.length - a.length);
|
|
36
|
+
if (!text || !aliases.length) return null;
|
|
37
|
+
const label = aliases.map(fieldAliasPattern).join('|');
|
|
38
|
+
const match = text.match(new RegExp(`(?:^|\\n)[^\\p{L}\\p{N}\\n]{0,10}(?:${label})\\s*[::—-]\\s*([^\\n]{1,${maxLength}})`, 'iu'));
|
|
39
|
+
return match?.[1]?.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractCandidateStructuredField(value, key, maxLength = 220) {
|
|
43
|
+
return extractField(value, CANDIDATE_FIELD_TERMS[key], FIELD_EXTRA_ALIASES.candidate[key] || [], maxLength);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractJobStructuredField(value, key, maxLength = 220) {
|
|
47
|
+
return extractField(value, JOB_FIELD_TERMS[key], FIELD_EXTRA_ALIASES.job[key] || [], maxLength);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function extractCandidateDisplayName(value) {
|
|
51
|
+
const text = String(value || '');
|
|
52
|
+
const structured = extractCandidateName(text);
|
|
53
|
+
if (structured) return structured;
|
|
54
|
+
const linkedIn = text.match(/(?:резюме|resume)\s*\|\s*(\p{Lu}\p{Ll}+(?:\s+\p{Lu}(?:\p{Ll}+|\.))?)/iu)?.[1]
|
|
55
|
+
|| text.match(/(?:^|\n)(\p{Lu}\p{Ll}+(?:\s+\p{Lu}\p{Ll}+)+)\s+-\s+(?:HR|Developer|Engineer|Manager|Designer)\b/iu)?.[1];
|
|
56
|
+
if (linkedIn) return linkedIn.trim().slice(0, 100);
|
|
57
|
+
const introduced = text.match(/(?:^|\n)(?:вітаю,?\s+)?мене\s+звати\s+(\p{Lu}\p{Ll}+)/iu)?.[1];
|
|
58
|
+
if (introduced) return introduced.trim().slice(0, 100);
|
|
59
|
+
const handleName = text.match(/@(\p{Lu}\p{Ll}{2,})(?:_|\p{Lu}|\d)/u)?.[1];
|
|
60
|
+
if (handleName) return handleName.trim().slice(0, 100);
|
|
61
|
+
return text.match(/(?:^|\n)(\p{Lu}\p{Ll}+)\s+\d+\+?\s+(?:рок\p{L}*|лет|years?)\s+(?:у|в|in)\s+(?:HR|IT)\b/iu)?.[1]?.trim().slice(0, 100) || '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const APPROXIMATE_EXPERIENCE_RE = /(?:\b(?:about|around|approximately|approx\.?|circa)\b|(?:примерно|около|приблизно|близько|yaqin|atrofida|qariyb))/iu;
|
|
65
|
+
|
|
66
|
+
function approximateExperienceSignal(context) {
|
|
67
|
+
return APPROXIMATE_EXPERIENCE_RE.test(context) ? { approximate: true } : {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validYears(raw) {
|
|
71
|
+
const years = Number(String(raw || '').replace(',', '.'));
|
|
72
|
+
return Number.isFinite(years) && years >= 0 && years <= 60 ? years : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function extractCandidateExperienceMentions(value) {
|
|
76
|
+
const text = String(value || '');
|
|
77
|
+
const mentions = [];
|
|
78
|
+
const segments = text.split(/\n|(?<=[.!?])\s+/u).map((segment) => segment.trim()).filter(Boolean);
|
|
79
|
+
for (const segment of segments) {
|
|
80
|
+
const months = segment.match(/(?:опыт(?:\s+работы)?|досвід(?:\s+роботи)?|experience|experiență|staj|tajriba(?:m)?|ish\s+tajribasi)[^\n.!?]{0,100}?(\d{1,2})\s*(?:мес(?:яц\p{L}*)?\.?|міс(?:яц\p{L}*)?\.?|months?|mo\b|oy(?:lik)?)/iu);
|
|
81
|
+
if (months?.[1]) {
|
|
82
|
+
const count = Number(months[1]);
|
|
83
|
+
if (Number.isFinite(count) && count > 0 && count < 24) {
|
|
84
|
+
const from = (months.index || 0) + months[0].length;
|
|
85
|
+
const context = `${months[0]} ${segment.slice(from, from + 100)}`.replace(/\s+/g, ' ').trim();
|
|
86
|
+
mentions.push(Object.freeze({ years: Math.round((count / 12) * 10) / 10, context, ...approximateExperienceSignal(context) }));
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const reverse = segment.match(/(\d+(?:[.,]\d+)?)\+?\s*(?:лет|год(?:а|ов)?|рок(?:и|ів)?|years?|ani|an|yil(?:ga|lik)?|йил(?:га|лик)?)[^\n.!?]{0,140}?(?:опыт|досвід|experience|experiență|staj|tajriba(?:m)?)/iu);
|
|
91
|
+
if (reverse?.[1]) {
|
|
92
|
+
const years = validYears(reverse[1]);
|
|
93
|
+
if (years != null) {
|
|
94
|
+
const context = reverse[0].replace(/\s+/g, ' ').trim();
|
|
95
|
+
mentions.push(Object.freeze({ years, context, ...approximateExperienceSignal(context) }));
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const direct = segment.match(/(?:опыт(?:\s+работы)?|досвід(?:\s+роботи)?|experience|experiență|staj|tajriba(?:m)?|ish\s+tajribasi)[^\n.!?]{0,100}?(\d+(?:[.,]\d+)?)\+?\s*(?:лет|год(?:а|ов)?|рок(?:и|ів)?|years?|ani|an|yil|йил)?/iu);
|
|
100
|
+
if (!direct?.[1]) continue;
|
|
101
|
+
const years = validYears(direct[1]);
|
|
102
|
+
if (years == null) continue;
|
|
103
|
+
const tailStart = (direct.index || 0) + direct[0].length;
|
|
104
|
+
const context = `${direct[0]} ${segment.slice(tailStart, tailStart + 100)}`.replace(/\s+/g, ' ').trim();
|
|
105
|
+
mentions.push(Object.freeze({ years, context, ...approximateExperienceSignal(context) }));
|
|
106
|
+
}
|
|
107
|
+
return Object.freeze(mentions);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EXTRA_CURRENCIES = Object.freeze([
|
|
111
|
+
['TJS', /(?:\bTJS\b|сомони)/iu],
|
|
112
|
+
['TMT', /(?:\bTMT\b|манат\p{L}*)/iu],
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
function sourceCurrency(text) {
|
|
116
|
+
for (const [code, pattern] of EXTRA_CURRENCIES) if (pattern.test(text)) return code;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseLooseNumber(raw, suffix = '') {
|
|
121
|
+
const scale = /^(?:кк|kk|млн|mln|million)$/iu.test(suffix) ? 1_000_000
|
|
122
|
+
: /^(?:к|k|тыс|тис|ming|thousand)$/iu.test(suffix) ? 1_000
|
|
123
|
+
: 1;
|
|
124
|
+
const value = String(raw || '').replace(/\u00a0/g, ' ').replace(/\s+/g, '').trim();
|
|
125
|
+
if (!value) return null;
|
|
126
|
+
let base;
|
|
127
|
+
if (/^\d{1,3}([.,])\d{3}(?:\1\d{3})*$/.test(value)) base = Number(value.replace(/[.,]/g, ''));
|
|
128
|
+
else if (scale > 1 && /^\d+[.,]\d{1,2}$/.test(value)) base = Number(value.replace(',', '.'));
|
|
129
|
+
else base = Number(value.replace(/[.,]/g, ''));
|
|
130
|
+
const parsed = Math.round(base * scale);
|
|
131
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const SOURCE_AMOUNT_RE = /(\d[\d\s.,]*\d|\d)(?:\s*(кк|kk|млн|mln|million|к|k|тыс|тис|ming|thousand))?(?:\s*(?:[-–—…]{1,3}|до|to|dan\s+gacha|gacha)\s*(\d[\d\s.,]*\d|\d)(?:\s*(кк|kk|млн|mln|million|к|k|тыс|тис|ming|thousand))?)?/iu;
|
|
135
|
+
|
|
136
|
+
export function parseHiringSourceSalary(value) {
|
|
137
|
+
const text = String(value || '').replace(/(?<=\d)\s*(?:кк|kk)(?![\p{L}\p{N}_])/giu, ' mln ');
|
|
138
|
+
const parsed = parseSalary(text);
|
|
139
|
+
if (parsed?.currency && (parsed.min != null || parsed.max != null)) return parsed;
|
|
140
|
+
const currency = sourceCurrency(text);
|
|
141
|
+
if (!currency) return parsed;
|
|
142
|
+
const match = text.match(SOURCE_AMOUNT_RE);
|
|
143
|
+
if (!match) return parsed;
|
|
144
|
+
const first = parseLooseNumber(match[1], match[2]);
|
|
145
|
+
const second = match[3] ? parseLooseNumber(match[3], match[4]) : null;
|
|
146
|
+
if (first == null && second == null) return parsed;
|
|
147
|
+
const low = first ?? second;
|
|
148
|
+
const high = second ?? first;
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
min: low != null && high != null ? Math.min(low, high) : low,
|
|
151
|
+
max: low != null && high != null ? Math.max(low, high) : high,
|
|
152
|
+
currency,
|
|
153
|
+
period: parsed?.period ?? null,
|
|
154
|
+
gross: parsed?.gross ?? null,
|
|
155
|
+
negotiable: parsed?.negotiable ?? false,
|
|
156
|
+
approximate: parsed?.approximate ?? false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseCandidateSalary(value, country = '') {
|
|
161
|
+
const field = extractCandidateStructuredField(value, 'salary', 140);
|
|
162
|
+
if (!field) return null;
|
|
163
|
+
const defaultCurrency = defaultHiringCurrency(country);
|
|
164
|
+
let parsed = parseHiringSourceSalary(field);
|
|
165
|
+
if (!parsed || (parsed.min == null && parsed.max == null)) {
|
|
166
|
+
const match = field.match(SOURCE_AMOUNT_RE);
|
|
167
|
+
if (!match) return null;
|
|
168
|
+
const first = parseLooseNumber(match[1], match[2]);
|
|
169
|
+
const second = match[3] ? parseLooseNumber(match[3], match[4]) : null;
|
|
170
|
+
if (first == null && second == null) return null;
|
|
171
|
+
const low = first ?? second;
|
|
172
|
+
const high = second ?? first;
|
|
173
|
+
parsed = Object.freeze({
|
|
174
|
+
min: low != null && high != null ? Math.min(low, high) : low,
|
|
175
|
+
max: /\+/.test(field) ? null : (low != null && high != null ? Math.max(low, high) : high),
|
|
176
|
+
currency: defaultCurrency,
|
|
177
|
+
period: null,
|
|
178
|
+
gross: null,
|
|
179
|
+
negotiable: false,
|
|
180
|
+
approximate: false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return Object.freeze({ ...parsed, currency: parsed.currency || defaultCurrency });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function detectUsLocation(value) {
|
|
187
|
+
return /\bunited states\b|\busa\b|\bu\.s\.?\b|\bUS(?:\s+remote)?\b|\b(?:AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC)\b/i.test(String(value || ''));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const NEGATIVE_SPONSORSHIP_RE = /(?:\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|\bdo(?:es)?\s+not\s+(?:offer|provide)\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:(?:current\s+(?:and\/or|or)\s+future|current|future)\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,80}\bwithout\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?sponsorship\b|\bmust\s+not\s+require\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?(?:visa\s+|employment\s+)?sponsorship\b|\b(?:current\s+and\/or\s+future|current\s+or\s+future)\s+sponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bno\s+c2c(?:\s+or\s+visa\s+sponsorship)?\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(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/iu;
|
|
191
|
+
const POSITIVE_SPONSORSHIP_RE = /(?:\bwill\s+sponsor\b|\bwe\s+sponsor\b|\b(?:can|may)\s+sponsor\b|\bopen\s+to\s+(?:visa\s+)?sponsorship\b|\bvisa\s+sponsorship\s+(?:is\s+)?(?:available|provided|offered|possible)\b|\b(?:h-?1b|h1-b)\s+(?:visa\s+)?sponsorship\b|\bh-?1b\s+transfer\b|\bimmigration\s+sponsorship\b|\bemployment\s+visa\s+sponsorship\b|\bwork\s+visa\s+sponsorship\b|\bsponsor(?:ing)?\s+(?:qualified|eligible|selected)\s+candidates\b|\beligible\s+for\s+(?:visa\s+)?sponsorship\b|\bvisa\s+support\b|\bwork\s+visa\s+support\b)/iu;
|
|
192
|
+
|
|
193
|
+
export function detectVisaSponsorshipWording(value) {
|
|
194
|
+
const text = String(value || '');
|
|
195
|
+
if (NEGATIVE_SPONSORSHIP_RE.test(text)) return 'notOffered';
|
|
196
|
+
if (POSITIVE_SPONSORSHIP_RE.test(text)) return 'offered';
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const TEMPORARY_WORK_AUTH_RE = /\b(?:opt|cpt|stem\s+opt)\b/iu;
|
|
201
|
+
|
|
202
|
+
export function detectRecruitmentAgency(value) {
|
|
203
|
+
return /recruit(?:ment|ing) agency|staffing agency|talent agency|кадров(?:ое|е) агентство|рекрут(?:ингов|инг)\p{L}* агентство|агентство по подбору/iu.test(String(value || ''));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const NICE_TO_HAVE_RE = /(will be a plus|is a plus|as a plus|nice to have|would be a plus|plus:|плюсом|будет плюсом|буде плюсом|перевагою|преимуществом|will be an advantage)/iu;
|
|
207
|
+
export function extractNiceToHaveContext(value, maxLength = 500) {
|
|
208
|
+
const text = String(value || '');
|
|
209
|
+
const match = NICE_TO_HAVE_RE.exec(text);
|
|
210
|
+
if (!match?.index && match?.index !== 0) return '';
|
|
211
|
+
return text.slice(match.index + match[0].length, match.index + match[0].length + Math.max(40, Math.min(1500, Number(maxLength) || 500))).trim();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CANDIDATE_FORM_RE = /(?:^|\n)\s*[^\p{L}\p{N}\n]{0,8}(?:ism(?:i|im)?(?:\s*[-–—]\s*(?:familya|familiya))?|familya|familiya|f\.?i\.?o\.?|фио|имя|yoshi|yoshim|tug(?:['’‘])ilgan\s+yili|возраст|qidirayotgan\s+kasb|so(?:['’‘])ralgan\s+ish\s+(?:joyi|turi)|yashash\s+manzili|ma(?:['’‘])lumoti|ожидаемая\s+работа|желаемая\s+(?:должность|работа)|tajribasi?|опыт\s+работы)\s*[:—-]/imu;
|
|
215
|
+
const CV_MARKER_RE = /(?:резюме|resume|\bcv\b|curriculum vitae|анкета|профиль кандидата|профіль кандидата|кандидат(?:ка)?|candidate profile|mening\s+(?:cv|rezume)|my\s+cv)/iu;
|
|
216
|
+
const FIRST_PERSON_CANDIDATE_RE = /(?:^|\n)\s*[^\p{L}\p{N}\n]{0,6}(?:я[\s—,-]|я\s+(?:ищу|шукаю)(?![\p{L}\p{N}_])|(?:ищу|шукаю)(?![\p{L}\p{N}_])|men[\s,]|mening[\s,]|my name is|i am a|i'm a|ismim\b)/iu;
|
|
217
|
+
const PERSONAL_PROFILE_RE = /(?:^|[^\p{L}\p{N}_])(?:(?:1[6-9]|[2-6]\d)\s*(?:лет|года?|рок(?:и|ів)?|years?\s+old)|(?:студент(?:ка|ом|кой)?|student))(?![\p{L}\p{N}_])/iu;
|
|
218
|
+
const CANDIDATE_CONTACT_RE = /(?:\+?\d[\d\s()\-]{7,}|@[a-z0-9_]{4,}|(?:telegram|телефон|phone|tel|aloqa|murojaat|bog(?:'|’)lanish)\s*[:—-])/iu;
|
|
219
|
+
const EMPTY_CANDIDATE_RECOMMENDATION_RE = /^(?:колеги[,!\s]*)?(?:вітаю[,!\s]*)?рекомендую\s+(?:класного\s+)?кандидат\p{L}*[.!\s]+(?:контакт\p{L}*\s+та\s+)?резюме\s+додаю\.?$/iu;
|
|
220
|
+
const CANDIDATE_SECTION_PATTERNS = Object.freeze([
|
|
221
|
+
/(?:опыт|досвід|experience|staj|tajriba|ish\s+tajribasi)/iu,
|
|
222
|
+
/(?:skills|навыки|навички|умею|стек|stack|technologies|texnologiyalar|ko(?:'|’)nikmalar)/iu,
|
|
223
|
+
/(?:education|образован|освіт|o(?:'|’)qish|ta(?:'|’)lim|университет|університет|university|college|institut)/iu,
|
|
224
|
+
/(?:languages|языки|мови|til(?:lar)?|language skills)/iu,
|
|
225
|
+
/(?:contact|контакт|telegram|телефон|phone|tel|bog(?:'|’)lanish|aloqa)/iu,
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
export function detectCandidatePostSignals(value) {
|
|
229
|
+
const text = String(value || '').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n').trim();
|
|
230
|
+
return Object.freeze({
|
|
231
|
+
candidateForm: CANDIDATE_FORM_RE.test(text),
|
|
232
|
+
cvMarker: CV_MARKER_RE.test(text),
|
|
233
|
+
firstPerson: FIRST_PERSON_CANDIDATE_RE.test(text),
|
|
234
|
+
personalProfile: PERSONAL_PROFILE_RE.test(text),
|
|
235
|
+
contact: CANDIDATE_CONTACT_RE.test(text),
|
|
236
|
+
emptyRecommendation: EMPTY_CANDIDATE_RECOMMENDATION_RE.test(text.replace(/\s+/g, ' ')),
|
|
237
|
+
sectionCount: CANDIDATE_SECTION_PATTERNS.filter((pattern) => pattern.test(text)).length,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function candidateBlockLabels(key) {
|
|
242
|
+
return entryAliases(CANDIDATE_FIELD_TERMS[key], FIELD_EXTRA_ALIASES.candidate[key] || [])
|
|
243
|
+
.sort((a, b) => b.length - a.length)
|
|
244
|
+
.map(fieldAliasPattern);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function extractCandidateStructuredBlock(value, key, maxLength = 800) {
|
|
248
|
+
const text = String(value || '');
|
|
249
|
+
const labels = candidateBlockLabels(key);
|
|
250
|
+
if (!text || !labels.length) return null;
|
|
251
|
+
const label = labels.join('|');
|
|
252
|
+
const allLabels = Object.keys(CANDIDATE_FIELD_TERMS)
|
|
253
|
+
.flatMap((candidateKey) => candidateBlockLabels(candidateKey))
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join('|');
|
|
256
|
+
const match = text.match(new RegExp(`(?:^|\\n)[^\\p{L}\\p{N}\\n]{0,10}(?:${label})\\s*[::—-]?\\s*\\n([\\s\\S]{1,${maxLength}}?)(?=\\n[^\\p{L}\\p{N}\\n]{0,10}(?:${allLabels})\\s*[::—-]|$)`, 'iu'));
|
|
257
|
+
return match?.[1]?.replace(/\s+/g, ' ').trim() || null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function defaultHiringCurrency(country) {
|
|
261
|
+
return countryCurrency(country);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const HIRING_CHARITY_APPEAL_RE = /(?:шелтер|притулок|прихисток|благодійн\p{L}*|благотворительн\p{L}*|донат\p{L}*|пожертв\p{L}*|збір\s+(?:кошт|грош)\p{L}*|сбор\s+средств|допоможіть|допомогти\s+(?:родин|дідус|бабус)\p{L}*|потребує\s+допомоги|нуждается\s+в\s+помощи|опікунств\p{L}*|інвалідніст\p{L}*|карта\s+для\s+допомоги|реквізити\s+для|monobank|банка\s+збор)/iu;
|
|
265
|
+
|
|
266
|
+
export function isHiringCharityAppeal(value) {
|
|
267
|
+
const text = String(value || '');
|
|
268
|
+
if (!text) return false;
|
|
269
|
+
const matches = text.match(new RegExp(HIRING_CHARITY_APPEAL_RE.source, 'giu')) || [];
|
|
270
|
+
return matches.length >= 2;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const RECRUITING_OPPORTUNITY_SIGNALS = Object.freeze([
|
|
274
|
+
/(?:\blaboratory\b|\bacademy\b|\bbootcamp\b|\btraining\s+program\b|\binternship\s+program\b|лабораторія|лаборатория|академія|академия|буткемп)/iu,
|
|
275
|
+
/(?:запрошує|приглашает|приглашаем|набір|набор)[^.]{0,100}(?:кандидат|учасник|участник)/iu,
|
|
276
|
+
/(?:(?:реєстрац|регистрац)\p{L}*\s+до|\bregistration\b\s+(?:until|by))/iu,
|
|
277
|
+
/(?:(?:старт|початок)\s*[—:,-]?\s*\d{1,2}\s+\p{L}+|\bstart\b\s*[—:,-]?\s*\d{1,2})/iu,
|
|
278
|
+
/(?:кількість\s+місць|количество\s+мест|\blimited\s+spots\b|менторськ|менторск|\bmentorship\b)/iu,
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
export function isHiringRecruitingOpportunity(value) {
|
|
282
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
283
|
+
if (!text) return false;
|
|
284
|
+
return RECRUITING_OPPORTUNITY_SIGNALS.filter((pattern) => pattern.test(text)).length >= 2;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function sameHiringProfessionFamily(a, b) {
|
|
288
|
+
const left = String(a || '').trim().toLowerCase().replace(/[_-]+/g, ' ');
|
|
289
|
+
const right = String(b || '').trim().toLowerCase().replace(/[_-]+/g, ' ');
|
|
290
|
+
if (!left || !right) return false;
|
|
291
|
+
if (left === right) return true;
|
|
292
|
+
return /(?:^|\s)developer$/u.test(left) && /(?:^|\s)developer$/u.test(right);
|
|
293
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const HIRING_MONTHS: Readonly<Record<string, number>>;
|
|
2
|
+
export const UNICODE_LEFT_BOUNDARY: string;
|
|
3
|
+
export const UNICODE_RIGHT_BOUNDARY: string;
|
|
4
|
+
export const TODAY_RE: RegExp;
|
|
5
|
+
export const YESTERDAY_RE: RegExp;
|
|
6
|
+
export const HOURS_AGO_RE: RegExp;
|
|
7
|
+
export const DAYS_AGO_RE: RegExp;
|
|
8
|
+
export const AGO_SUFFIX: string;
|
|
9
|
+
export const WEEKS_AGO_RE: RegExp;
|
|
10
|
+
export const MONTHS_AGO_RE: RegExp;
|
|
11
|
+
export const YEARS_AGO_RE: RegExp;
|
|
12
|
+
|
|
13
|
+
export function parseHiringActivityDate(value: unknown, now?: Date): string | null;
|
|
14
|
+
export function parseHiringDayMonthDate(value: unknown, now?: Date): string | null;
|
|
15
|
+
export function extractHiringDeadline(value: unknown): string | null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const HIRING_MONTHS = Object.freeze({
|
|
2
|
+
январь: 0, января: 0, february: 1, февраль: 1, февраля: 1,
|
|
3
|
+
март: 2, марта: 2, april: 3, апрель: 3, апреля: 3,
|
|
4
|
+
may: 4, май: 4, мая: 4, june: 5, июнь: 5, июня: 5,
|
|
5
|
+
july: 6, июль: 6, июля: 6, august: 7, август: 7, августа: 7,
|
|
6
|
+
september: 8, сентябрь: 8, сентября: 8, october: 9, октябрь: 9, октября: 9,
|
|
7
|
+
november: 10, ноябрь: 10, ноября: 10, december: 11, декабрь: 11, декабря: 11,
|
|
8
|
+
ianuarie: 0, februarie: 1, martie: 2, aprilie: 3, mai: 4, iunie: 5,
|
|
9
|
+
iulie: 6, august: 7, septembrie: 8, octombrie: 9, noiembrie: 10, decembrie: 11,
|
|
10
|
+
січня: 0, лютого: 1, березня: 2, квітня: 3, травня: 4, червня: 5,
|
|
11
|
+
липня: 6, серпня: 7, вересня: 8, жовтня: 9, листопада: 10, грудня: 11,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const UNICODE_LEFT_BOUNDARY = '(?<![\\p{L}\\p{N}])';
|
|
15
|
+
export const UNICODE_RIGHT_BOUNDARY = '(?![\\p{L}\\p{N}])';
|
|
16
|
+
export const TODAY_RE = new RegExp(`${UNICODE_LEFT_BOUNDARY}(?:сегодня|сьогодні|bugun|today|astăzi|azi)${UNICODE_RIGHT_BOUNDARY}`, 'iu');
|
|
17
|
+
export const YESTERDAY_RE = new RegExp(`${UNICODE_LEFT_BOUNDARY}(?:вчера|вчора|kecha|yesterday|ieri)${UNICODE_RIGHT_BOUNDARY}`, 'iu');
|
|
18
|
+
export const HOURS_AGO_RE = new RegExp(`(?:^|\\s)(\\d{1,3})\\s*(?:ч\\.?|час(?:а|ов)?|год(?:ину|ини)|soat|hours?|hrs?|ore|oră)${UNICODE_RIGHT_BOUNDARY}`, 'iu');
|
|
19
|
+
export const DAYS_AGO_RE = new RegExp(`(?:^|\\s)(\\d{1,3})\\s*(?:дн(?:я|ей|і|ів)?|день|days?|kun|zile|zi)${UNICODE_RIGHT_BOUNDARY}`, 'iu');
|
|
20
|
+
export const AGO_SUFFIX = '(?:\\s*(?:назад|тому|раніше|oldin|ago|în urmă))';
|
|
21
|
+
export const WEEKS_AGO_RE = new RegExp(`(?:^|\\s)(\\d{1,2})\\s*(?:недел(?:ю|и|ь)|тижн(?:ів|і|я)|hafta|weeks?|săptămân\\p{L}*)${AGO_SUFFIX}`, 'iu');
|
|
22
|
+
export const MONTHS_AGO_RE = new RegExp(`(?:^|\\s)(\\d{1,2})\\s*(?:мес(?:яц\\p{L}*)?\\.?|міс(?:яц\\p{L}*)?\\.?|oy|months?|lun\\p{L}*)${AGO_SUFFIX}`, 'iu');
|
|
23
|
+
export const YEARS_AGO_RE = new RegExp(`(?:^|\\s)(\\d{1,2})\\s*(?:год(?:а|ов)?|лет|рік|рок(?:и|ів)|yil|years?|ani|an)${AGO_SUFFIX}`, 'iu');
|
|
24
|
+
|
|
25
|
+
export function parseHiringActivityDate(value, now = new Date()) {
|
|
26
|
+
const text = String(value || '');
|
|
27
|
+
if (TODAY_RE.test(text)) return now.toISOString();
|
|
28
|
+
if (YESTERDAY_RE.test(text)) return new Date(now.getTime() - 86_400_000).toISOString();
|
|
29
|
+
const absolute = text.match(/(?<![\p{L}\p{N}])(\d{1,2})\s+([\p{L}]+),?\s+(20\d{2})(?![\p{L}\p{N}])/iu);
|
|
30
|
+
if (absolute) {
|
|
31
|
+
const month = HIRING_MONTHS[absolute[2].toLocaleLowerCase('ru')];
|
|
32
|
+
if (month != null) return new Date(Date.UTC(Number(absolute[3]), month, Number(absolute[1]), 12)).toISOString();
|
|
33
|
+
}
|
|
34
|
+
const dotted = text.match(/(?<![\d])(\d{1,2})[./-](\d{1,2})[./-](20\d{2})(?![\d])/);
|
|
35
|
+
if (dotted) return new Date(Date.UTC(Number(dotted[3]), Number(dotted[2]) - 1, Number(dotted[1]), 12)).toISOString();
|
|
36
|
+
const hours = text.match(HOURS_AGO_RE);
|
|
37
|
+
if (hours) return new Date(now.getTime() - Number(hours[1]) * 3_600_000).toISOString();
|
|
38
|
+
const days = text.match(DAYS_AGO_RE);
|
|
39
|
+
if (days) return new Date(now.getTime() - Number(days[1]) * 86_400_000).toISOString();
|
|
40
|
+
const weeks = text.match(WEEKS_AGO_RE);
|
|
41
|
+
if (weeks) return new Date(now.getTime() - Number(weeks[1]) * 7 * 86_400_000).toISOString();
|
|
42
|
+
const months = text.match(MONTHS_AGO_RE);
|
|
43
|
+
if (months) return new Date(now.getTime() - Number(months[1]) * 30 * 86_400_000).toISOString();
|
|
44
|
+
const years = text.match(YEARS_AGO_RE);
|
|
45
|
+
if (years) return new Date(now.getTime() - Number(years[1]) * 365 * 86_400_000).toISOString();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseHiringDayMonthDate(value, now = new Date()) {
|
|
50
|
+
const text = String(value || '');
|
|
51
|
+
const match = text.match(new RegExp(`${UNICODE_LEFT_BOUNDARY}(\\d{1,2})\\s+(\\p{L}+)${UNICODE_RIGHT_BOUNDARY}`, 'iu'));
|
|
52
|
+
if (!match) return null;
|
|
53
|
+
const month = HIRING_MONTHS[match[2].toLocaleLowerCase('ru')];
|
|
54
|
+
if (month == null) return null;
|
|
55
|
+
const day = Number(match[1]);
|
|
56
|
+
let time = Date.UTC(now.getUTCFullYear(), month, day, 12);
|
|
57
|
+
if (time > now.getTime() + 48 * 60 * 60 * 1000) time = Date.UTC(now.getUTCFullYear() - 1, month, day, 12);
|
|
58
|
+
return new Date(time).toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEADLINE_MARKER_RE = /(?:application\s+deadline|apply\s+by|closing\s+date|deadline|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)[\s:–—-]{0,8}([^.;\n]{3,55})/iu;
|
|
62
|
+
const DEADLINE_DATE_RE = /\b(?:\d{4}-\d{2}-\d{2}|\d{1,2}[./-]\d{1,2}[./-]\d{2,4}|\d{1,2}\s+(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|янв\p{L}*|фев\p{L}*|мар\p{L}*|апр\p{L}*|ма[йя]|июн\p{L}*|июл\p{L}*|авг\p{L}*|сен\p{L}*|окт\p{L}*|ноя\p{L}*|дек\p{L}*)\.?\s+\d{2,4})\b/iu;
|
|
63
|
+
|
|
64
|
+
export function extractHiringDeadline(value) {
|
|
65
|
+
const marker = DEADLINE_MARKER_RE.exec(String(value || ''));
|
|
66
|
+
return marker?.[1]?.match(DEADLINE_DATE_RE)?.[0]?.trim() || null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type HiringEducation = 'doctorate' | 'master' | 'bachelor' | 'higher' | 'secondary';
|
|
2
|
+
export type HiringApplicationLanguage = 'English' | 'Russian' | 'Ukrainian' | 'Uzbek' | 'Kazakh' | 'Romanian';
|
|
3
|
+
|
|
4
|
+
export function detectHiringEducation(value: unknown): HiringEducation | null;
|
|
5
|
+
export function detectApplicationLanguage(value: unknown): HiringApplicationLanguage | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { detectDegreeLevel } from './hiring-requirements.js';
|
|
2
|
+
|
|
3
|
+
export function detectHiringEducation(value) {
|
|
4
|
+
const text = String(value || '');
|
|
5
|
+
const degree = detectDegreeLevel(text);
|
|
6
|
+
if (degree) return degree;
|
|
7
|
+
if (/higher education|высшее образование|вища освіта|олий маълумот/iu.test(text)) return 'higher';
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function detectApplicationLanguage(value) {
|
|
12
|
+
const text = String(value || '');
|
|
13
|
+
const match = /(?:submit|send|provide|отправ\p{L}*|пришл\p{L}*|направ\p{L}*|подай\p{L}*|надішл\p{L}*)[^.;\n]{0,35}(?:application|resume|résumé|cv|резюме)[^.;\n]{0,20}[^\p{L}\p{N}](?:in|на|у|в)\s+(english|russian|ukrainian|uzbek|kazakh|romanian|английском|русском|украинском|українською|українській|узбекском|казахском|румынском)(?![\p{L}\p{N}])/iu.exec(text);
|
|
14
|
+
if (!match?.[1]) return null;
|
|
15
|
+
const language = match[1].toLowerCase();
|
|
16
|
+
if (/english|англий/.test(language)) return 'English';
|
|
17
|
+
if (/russian|русск/.test(language)) return 'Russian';
|
|
18
|
+
if (/ukrain|укра/.test(language)) return 'Ukrainian';
|
|
19
|
+
if (/uzbek|узбек/.test(language)) return 'Uzbek';
|
|
20
|
+
if (/kazakh|казах/.test(language)) return 'Kazakh';
|
|
21
|
+
if (/romanian|румын/.test(language)) return 'Romanian';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type HiringEmploymentType =
|
|
2
|
+
| 'full_time'
|
|
3
|
+
| 'part_time'
|
|
4
|
+
| 'contract'
|
|
5
|
+
| 'project'
|
|
6
|
+
| 'freelance'
|
|
7
|
+
| 'temporary'
|
|
8
|
+
| 'internship'
|
|
9
|
+
| 'volunteer'
|
|
10
|
+
| 'seasonal';
|
|
11
|
+
|
|
12
|
+
export type HiringWorkMode = 'remote' | 'hybrid' | 'onsite';
|
|
13
|
+
|
|
14
|
+
export type HiringWorkSchedule =
|
|
15
|
+
| 'fiveTwo'
|
|
16
|
+
| 'twoTwo'
|
|
17
|
+
| 'sixOne'
|
|
18
|
+
| 'threeThree'
|
|
19
|
+
| 'oneThree'
|
|
20
|
+
| 'twentyFourFortyEight'
|
|
21
|
+
| 'shift'
|
|
22
|
+
| 'flexible'
|
|
23
|
+
| 'day'
|
|
24
|
+
| 'night'
|
|
25
|
+
| 'rotational';
|
|
26
|
+
|
|
27
|
+
export type HiringProbationKind = 'probation' | 'noProbation' | 'paidProbation' | 'unpaidProbation';
|
|
28
|
+
export type HiringExperienceRequirement = 'noExperience' | 'experienceRequired';
|
|
29
|
+
|
|
30
|
+
export function detectEmploymentTypes(value: unknown): readonly HiringEmploymentType[];
|
|
31
|
+
export function detectWorkModes(value: unknown): readonly HiringWorkMode[];
|
|
32
|
+
export function detectWorkSchedules(value: unknown): readonly HiringWorkSchedule[];
|
|
33
|
+
export function detectProbation(value: unknown): HiringProbationKind | null;
|
|
34
|
+
export function detectExperienceRequirement(value: unknown): HiringExperienceRequirement | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { findCanonical } from './normalization.js';
|
|
2
|
+
import {
|
|
3
|
+
EMPLOYMENT_TYPES,
|
|
4
|
+
EXPERIENCE_REQUIREMENTS,
|
|
5
|
+
PROBATION_TERMS,
|
|
6
|
+
SCHEDULE_TERMS,
|
|
7
|
+
WORK_MODES,
|
|
8
|
+
} from './hiring.js';
|
|
9
|
+
import { parseExperience, WORK_SCHEDULE_EXTENSIONS } from './hiring-advanced.js';
|
|
10
|
+
|
|
11
|
+
const EMPLOYMENT_OUTPUT = Object.freeze({
|
|
12
|
+
fullTime: 'full_time',
|
|
13
|
+
partTime: 'part_time',
|
|
14
|
+
contract: 'contract',
|
|
15
|
+
project: 'project',
|
|
16
|
+
freelance: 'freelance',
|
|
17
|
+
temporary: 'temporary',
|
|
18
|
+
internship: 'internship',
|
|
19
|
+
volunteer: 'volunteer',
|
|
20
|
+
seasonal: 'seasonal',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const PROBATION_MATCH_ORDER = Object.freeze([
|
|
24
|
+
PROBATION_TERMS.paid,
|
|
25
|
+
PROBATION_TERMS.unpaid,
|
|
26
|
+
PROBATION_TERMS.noProbation,
|
|
27
|
+
PROBATION_TERMS.probation,
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function collectCanonical(text, entries) {
|
|
31
|
+
const values = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!findCanonical(text, [entry], { partial: true })) continue;
|
|
34
|
+
if (!values.includes(entry.canonical)) values.push(entry.canonical);
|
|
35
|
+
}
|
|
36
|
+
return values;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function detectEmploymentTypes(value) {
|
|
40
|
+
return Object.freeze(
|
|
41
|
+
collectCanonical(String(value || ''), EMPLOYMENT_TYPES)
|
|
42
|
+
.map((canonical) => EMPLOYMENT_OUTPUT[canonical])
|
|
43
|
+
.filter(Boolean),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function detectWorkModes(value) {
|
|
48
|
+
return Object.freeze(collectCanonical(String(value || ''), WORK_MODES));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function detectWorkSchedules(value) {
|
|
52
|
+
return Object.freeze(collectCanonical(String(value || ''), [...SCHEDULE_TERMS, ...WORK_SCHEDULE_EXTENSIONS]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function detectProbation(value) {
|
|
56
|
+
const text = String(value || '');
|
|
57
|
+
for (const entry of PROBATION_MATCH_ORDER) {
|
|
58
|
+
if (findCanonical(text, [entry], { partial: true })) return entry.canonical;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function detectExperienceRequirement(value) {
|
|
64
|
+
const text = String(value || '');
|
|
65
|
+
const parsed = parseExperience(text);
|
|
66
|
+
if (parsed?.requirement === 'none') return 'noExperience';
|
|
67
|
+
if (parsed?.requirement === 'required') return 'experienceRequired';
|
|
68
|
+
for (const entry of Object.values(EXPERIENCE_REQUIREMENTS)) {
|
|
69
|
+
if (findCanonical(text, [entry], { partial: true })) return entry.canonical;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|