@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,293 +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
|
-
}
|
|
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
|
+
}
|
package/src/hiring-temporal.d.ts
CHANGED
|
@@ -1,15 +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;
|
|
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;
|