@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,147 @@
|
|
|
1
|
+
import { aliasesToRegex, findCanonical, normalizeUnicode } from './normalization.js';
|
|
2
|
+
import { HIRING_INTENT, JOB_FIELD_TERMS } from './hiring.js';
|
|
3
|
+
import { lexiconEntity } from './lexicon-core.js';
|
|
4
|
+
|
|
5
|
+
const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
|
|
6
|
+
|
|
7
|
+
export const HIRING_INTENT_EXTENSIONS = Object.freeze({
|
|
8
|
+
candidate: group('candidate', {
|
|
9
|
+
ru: ['рассмотрю предложения', 'в поиске работы', 'ищу подработку', 'готов выйти на работу', 'ищу удаленку', 'ищу удалёнку'],
|
|
10
|
+
en: ['looking for opportunities', 'available for work', 'open for opportunities', 'seeking opportunities'],
|
|
11
|
+
uk: ['розгляну пропозиції', 'у пошуку роботи', 'шукаю підробіток', 'готовий вийти на роботу'],
|
|
12
|
+
ro: ['caut oportunități', 'caut oportunitati', 'disponibil pentru muncă', 'disponibil pentru munca'],
|
|
13
|
+
uzLatn: ['ish izlayapman', 'ish takliflarini korib chiqaman', "ish takliflarini ko'rib chiqaman", 'qoshimcha ish qidiraman'],
|
|
14
|
+
uzCyrl: ['иш излаяпман', 'иш таклифларини кўриб чиқаман', 'қўшимча иш қидираман'],
|
|
15
|
+
kk: ['жұмыс ұсыныстарын қараймын', 'жұмыс іздеп жүрмін', 'қосымша жұмыс іздеймін'],
|
|
16
|
+
}),
|
|
17
|
+
employer: group('employer', {
|
|
18
|
+
ru: ['есть работа', 'набор персонала', 'ищем в команду', 'расширяем команду', 'открыта позиция', 'открыта вакансия', 'приглашаем на работу', 'в нашу команду нужен', 'в команду требуется', '#вакансия'],
|
|
19
|
+
en: ['join our team', 'open position', 'open role', 'we are hiring', 'now hiring', 'hiring now', 'join the team'],
|
|
20
|
+
uk: ['є робота', 'набір персоналу', 'шукаємо в команду', 'відкрита позиція', 'відкрита вакансія', 'запрошуємо на роботу'],
|
|
21
|
+
ro: ['angajări', 'angajari', 'recrutăm', 'recrutam', 'poziție deschisă', 'pozitie deschisa', 'alătură-te echipei', 'alatura-te echipei'],
|
|
22
|
+
uzLatn: ['ishga qabul qilamiz', 'jamoaga taklif qilamiz', 'jamoamizga xodim kerak', 'ochiq vakansiya', 'ishga olamiz'],
|
|
23
|
+
uzCyrl: ['ишга қабул қиламиз', 'жамоага таклиф қиламиз', 'жамоамизга ходим керак', 'очиқ вакансия', 'ишга оламиз'],
|
|
24
|
+
kk: ['жұмысқа қабылдаймыз', 'командаға қызметкер іздейміз', 'ашық вакансия', 'жұмысқа шақырамыз'],
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const HIRING_NEGATIVE_INTENT = Object.freeze([
|
|
29
|
+
group('closed', {
|
|
30
|
+
ru: ['вакансия закрыта', 'позиция закрыта', 'набор закрыт', 'уже нашли сотрудника', 'сотрудник найден', 'не актуально', 'вакансия не актуальна'],
|
|
31
|
+
en: ['position closed', 'vacancy closed', 'role filled', 'no longer hiring', 'position filled'],
|
|
32
|
+
uk: ['вакансія закрита', 'позиція закрита', 'набір закрито', 'вже знайшли працівника', 'не актуально'],
|
|
33
|
+
ro: ['post ocupat', 'poziție închisă', 'pozitie inchisa', 'nu mai angajăm', 'nu mai angajam'],
|
|
34
|
+
uzLatn: ['vakansiya yopildi', 'xodim topildi', 'qabul yopildi', 'endi aktual emas'],
|
|
35
|
+
uzCyrl: ['вакансия ёпилди', 'ходим топилди', 'қабул ёпилди', 'энди актуал эмас'],
|
|
36
|
+
kk: ['вакансия жабылды', 'қызметкер табылды', 'қабылдау жабылды'],
|
|
37
|
+
}),
|
|
38
|
+
group('notLooking', {
|
|
39
|
+
ru: ['не ищу работу', 'работу не ищу', 'не рассматриваю вакансии'],
|
|
40
|
+
en: ['not looking for work', 'not looking for a job', 'not open to work'],
|
|
41
|
+
uk: ['не шукаю роботу', 'роботу не шукаю'],
|
|
42
|
+
ro: ['nu caut de lucru', 'nu caut un loc de muncă'],
|
|
43
|
+
uzLatn: ['ish qidirmayapman', 'ish kerak emas'],
|
|
44
|
+
uzCyrl: ['иш қидирмаяпман', 'иш керак эмас'],
|
|
45
|
+
kk: ['жұмыс іздемеймін', 'жұмыс керек емес'],
|
|
46
|
+
}),
|
|
47
|
+
group('course', {
|
|
48
|
+
ru: ['курс профессии', 'обучение профессии', 'стань программистом', 'стань разработчиком', 'курс с трудоустройством', 'обучение с трудоустройством'],
|
|
49
|
+
en: ['become a developer', 'career course', 'job-ready course', 'bootcamp with job placement'],
|
|
50
|
+
uk: ['курс професії', 'навчання професії', 'стань програмістом'],
|
|
51
|
+
ro: ['curs de carieră', 'curs de programare cu angajare'],
|
|
52
|
+
uzLatn: ['kasb kursi', 'dasturchi bolish kursi', "dasturchi bo'lish kursi"],
|
|
53
|
+
uzCyrl: ['касб курси', 'дастурчи бўлиш курси'],
|
|
54
|
+
kk: ['мамандық курсы', 'бағдарламашы болу курсы'],
|
|
55
|
+
}),
|
|
56
|
+
group('advice', {
|
|
57
|
+
ru: ['как найти работу', 'как составить резюме', 'советы по поиску работы'],
|
|
58
|
+
en: ['how to find a job', 'how to write a resume', 'job search tips'],
|
|
59
|
+
uk: ['як знайти роботу', 'як скласти резюме'],
|
|
60
|
+
ro: ['cum să găsești un job', 'cum sa gasesti un job'],
|
|
61
|
+
uzLatn: ['ish qanday topiladi', 'rezyume qanday yoziladi'],
|
|
62
|
+
uzCyrl: ['иш қандай топилади', 'резюме қандай ёзилади'],
|
|
63
|
+
kk: ['жұмысты қалай табуға болады', 'түйіндемені қалай жазу керек'],
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export const VACANCY_FIELD_TERMS = Object.freeze({
|
|
68
|
+
...JOB_FIELD_TERMS,
|
|
69
|
+
company: group('company', { ru: ['компания', 'работодатель', 'о компании'], en: ['company', 'employer', 'about company'], uk: ['компанія', 'роботодавець'], ro: ['companie', 'angajator'], uzLatn: ['kompaniya', 'ish beruvchi'], uzCyrl: ['компания', 'иш берувчи'], kk: ['компания', 'жұмыс беруші'] }),
|
|
70
|
+
salary: group('salary', { ru: ['зарплата', 'зп', 'оклад', 'доход', 'оплата'], en: ['salary', 'compensation', 'pay', 'rate'], uk: ['зарплата', 'оплата', 'дохід'], ro: ['salariu', 'plată', 'plata', 'remunerație'], uzLatn: ['maosh', 'oylik', 'ish haqi'], uzCyrl: ['маош', 'ойлик', 'иш ҳақи'], kk: ['жалақы', 'айлық', 'еңбекақы'] }),
|
|
71
|
+
schedule: group('schedule', { ru: ['график', 'график работы', 'режим работы'], en: ['schedule', 'work schedule', 'hours'], uk: ['графік', 'графік роботи'], ro: ['program', 'program de lucru'], uzLatn: ['grafik', 'ish grafigi'], uzCyrl: ['график', 'иш графиги'], kk: ['жұмыс кестесі', 'кесте'] }),
|
|
72
|
+
location: group('location', { ru: ['локация', 'место работы', 'адрес работы'], en: ['location', 'work location', 'workplace'], uk: ['локація', 'місце роботи'], ro: ['locație', 'locatie', 'locul de muncă'], uzLatn: ['manzil', 'ish joyi'], uzCyrl: ['манзил', 'иш жойи'], kk: ['мекенжай', 'жұмыс орны'] }),
|
|
73
|
+
contact: group('contact', { ru: ['контакты', 'отклик', 'как откликнуться', 'связь'], en: ['contact', 'apply', 'how to apply'], uk: ['контакти', 'відгукнутися', 'як відгукнутися'], ro: ['contact', 'aplică', 'aplica'], uzLatn: ['aloqa', 'murojaat', 'boglanish'], uzCyrl: ['алоқа', 'мурожаат', 'боғланиш'], kk: ['байланыс', 'өтініш беру'] }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const WORK_SCHEDULE_EXTENSIONS = Object.freeze([
|
|
77
|
+
group('sixOne', { ru: ['6/1', 'шесть один', '6 на 1'], en: ['6/1', 'six days on one day off'], uk: ['6/1'], ro: ['6/1'], uzLatn: ['6/1'], uzCyrl: ['6/1'], kk: ['6/1'] }),
|
|
78
|
+
group('threeThree', { ru: ['3/3', 'три через три'], en: ['3/3', 'three on three off'], uk: ['3/3'], ro: ['3/3'], uzLatn: ['3/3'], uzCyrl: ['3/3'], kk: ['3/3'] }),
|
|
79
|
+
group('oneThree', { ru: ['1/3', 'сутки через трое'], en: ['1/3', '24 on 72 off'], uk: ['1/3', 'доба через три'], ro: ['1/3'], uzLatn: ['1/3'], uzCyrl: ['1/3'], kk: ['1/3'] }),
|
|
80
|
+
group('twentyFourFortyEight', { ru: ['24/48', 'сутки через двое'], en: ['24/48', '24 on 48 off'], uk: ['24/48', 'доба через дві'], ro: ['24/48'], uzLatn: ['24/48'], uzCyrl: ['24/48'], kk: ['24/48'] }),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
export const EXPERIENCE_MODIFIERS = Object.freeze([
|
|
84
|
+
group('none', { ru: ['без опыта', 'опыт не требуется', 'можно без опыта', 'обучаем'], en: ['no experience', 'no experience required', 'training provided'], uk: ['без досвіду', 'досвід не потрібен'], ro: ['fără experiență', 'fara experienta'], uzLatn: ['tajribasiz', 'tajriba shart emas'], uzCyrl: ['тажрибасиз', 'тажриба шарт эмас'], kk: ['тәжірибесіз', 'тәжірибе қажет емес'] }),
|
|
85
|
+
group('preferred', { ru: ['опыт приветствуется', 'желателен опыт', 'опыт будет плюсом'], en: ['experience preferred', 'experience is a plus', 'experience desirable'], uk: ['досвід бажаний', 'досвід буде плюсом'], ro: ['experiența constituie un avantaj', 'experienta constituie un avantaj'], uzLatn: ['tajriba bolsa yaxshi', "tajriba bo'lsa yaxshi"], uzCyrl: ['тажриба бўлса яхши'], kk: ['тәжірибе артықшылық болады'] }),
|
|
86
|
+
group('required', { ru: ['опыт обязателен', 'требуется опыт', 'необходим опыт'], en: ['experience required', 'must have experience'], uk: ['досвід обов’язковий', "досвід обов'язковий"], ro: ['experiență obligatorie', 'experienta obligatorie'], uzLatn: ['tajriba kerak', 'tajriba talab qilinadi'], uzCyrl: ['тажриба керак', 'тажриба талаб қилинади'], kk: ['тәжірибе қажет', 'тәжірибе міндетті'] }),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const YEAR_WORD = '(?:лет|года|год|рок(?:и|ів)?|years?|yrs?|ani|yil|жыл)';
|
|
90
|
+
const RANGE_RE = new RegExp(`(?:опыт|experience|досвід|experien[țt]a|tajriba|тәжірибе)?[^\\d]{0,18}(\\d+(?:[.,]\\d+)?)\\s*(?:-|–|—|до|to)\\s*(\\d+(?:[.,]\\d+)?)\\s*${YEAR_WORD}`, 'iu');
|
|
91
|
+
const MIN_RE = new RegExp(`(?:от|не\\s+менее|minimum|min\\.?|at\\s+least|від|de\\s+la|kamida|кемінде)?\\s*(\\d+(?:[.,]\\d+)?)\\s*\\+?\\s*${YEAR_WORD}`, 'iu');
|
|
92
|
+
|
|
93
|
+
function allAliases(entry) {
|
|
94
|
+
return Object.values(entry?.aliases || {}).flatMap((values) => Array.isArray(values) ? values : []);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function matcher(entry) {
|
|
98
|
+
return aliasesToRegex([entry.canonical, ...allAliases(entry)]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function detectHiringNegativeIntent(value) {
|
|
102
|
+
const text = String(value || '');
|
|
103
|
+
for (const entry of HIRING_NEGATIVE_INTENT) {
|
|
104
|
+
const match = text.match(matcher(entry));
|
|
105
|
+
if (match) return Object.freeze({ canonical: entry.canonical, matched: match[0].trim() });
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function classifyHiringIntent(value) {
|
|
111
|
+
const text = String(value || '');
|
|
112
|
+
const negative = detectHiringNegativeIntent(text);
|
|
113
|
+
if (negative) return Object.freeze({ intent: 'negative', reason: negative.canonical, matched: negative.matched, confidence: 1 });
|
|
114
|
+
|
|
115
|
+
for (const intent of ['employer', 'candidate']) {
|
|
116
|
+
const sources = [HIRING_INTENT[intent], HIRING_INTENT_EXTENSIONS[intent]];
|
|
117
|
+
for (const entry of sources) {
|
|
118
|
+
const match = text.match(matcher(entry));
|
|
119
|
+
if (match) return Object.freeze({ intent, reason: null, matched: match[0].trim(), confidence: 1 });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze({ intent: null, reason: null, matched: null, confidence: 0 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseExperience(value) {
|
|
126
|
+
const text = normalizeUnicode(value || '');
|
|
127
|
+
if (!text) return null;
|
|
128
|
+
const modifier = findCanonical(text, EXPERIENCE_MODIFIERS, { partial: true })?.canonical || null;
|
|
129
|
+
if (modifier === 'none') return Object.freeze({ requirement: 'none', minYears: 0, maxYears: 0 });
|
|
130
|
+
|
|
131
|
+
const range = text.match(RANGE_RE);
|
|
132
|
+
if (range) {
|
|
133
|
+
const minYears = Number(range[1].replace(',', '.'));
|
|
134
|
+
const maxYears = Number(range[2].replace(',', '.'));
|
|
135
|
+
return Object.freeze({ requirement: modifier || 'required', minYears: Math.min(minYears, maxYears), maxYears: Math.max(minYears, maxYears) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const single = text.match(MIN_RE);
|
|
139
|
+
if (single) {
|
|
140
|
+
const years = Number(single[1].replace(',', '.'));
|
|
141
|
+
const isExact = !/(?:от|не\s+менее|minimum|min\.?|at\s+least|від|kamida|кемінде|\+)/iu.test(single[0]);
|
|
142
|
+
return Object.freeze({ requirement: modifier || 'required', minYears: years, maxYears: isExact ? years : null });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (modifier) return Object.freeze({ requirement: modifier, minYears: null, maxYears: null });
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type CandidateGender = 'female' | 'male';
|
|
2
|
+
export type CandidateContacts = Readonly<{ phone?: string; email?: string; telegram?: string }>;
|
|
3
|
+
|
|
4
|
+
export function extractCandidateGender(value: unknown): CandidateGender | undefined;
|
|
5
|
+
export function extractCandidateName(value: unknown): string;
|
|
6
|
+
export function extractCandidateAge(value: unknown, now?: Date): number | null;
|
|
7
|
+
export function parseCandidateExperienceValue(value: unknown): number | null;
|
|
8
|
+
export function extractCandidateExperienceYears(value: unknown): number | null;
|
|
9
|
+
export function extractCandidateContacts(value: unknown, country?: string | null): CandidateContacts;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { findPhoneLikeSpans, findTelegramContacts, parsePhoneNumbers } from './contact.js';
|
|
2
|
+
import { countryPhoneHint } from './country-context.js';
|
|
3
|
+
|
|
4
|
+
const EXPLICIT_FEMALE_RE = /(?:^|[^\p{L}])(?:женщина|женский|девушка|female|ayol)(?=$|[^\p{L}])/iu;
|
|
5
|
+
const EXPLICIT_MALE_RE = /(?:^|[^\p{L}])(?:мужчина|мужской|парень|male|erkak)(?=$|[^\p{L}])/iu;
|
|
6
|
+
const FEMALE_LINEAGE_RE = /(?:^|[^\p{L}])(?:qizi|қизи|кизи|қызы)(?=$|[^\p{L}])/iu;
|
|
7
|
+
const MALE_LINEAGE_RE = /(?:^|[^\p{L}])(?:o(?:['’ʻʼ‘`])?g(?:['’ʻʼ‘`])?li|ўғли|угли|оғли|огли)(?=$|[^\p{L}])/iu;
|
|
8
|
+
const FEMALE_SURNAME_RE = /(?:^|[^\p{L}])\p{L}[\p{L}ёЁ-]{2,}(?:ова|ева|ёва|ина|ына|ская|цкая|ая)(?=$|[^\p{L}])/iu;
|
|
9
|
+
const MALE_SURNAME_RE = /(?:^|[^\p{L}])\p{L}[\p{L}ёЁ-]{2,}(?:ов|ев|ёв|ин|ын|ский|цкий|ой)(?=$|[^\p{L}])/iu;
|
|
10
|
+
const FEMALE_LATIN_SURNAME_RE = /(?:^|[^\p{L}])\p{L}[\p{L}'’ʻʼ‘`-]{2,}(?:ova|eva|ina|skaya|tskaya|aya)(?=$|[^\p{L}])/iu;
|
|
11
|
+
const MALE_LATIN_SURNAME_RE = /(?:^|[^\p{L}])\p{L}[\p{L}'’ʻʼ‘`-]{2,}(?:ov|ev|in|skiy|sky|tskiy|oy)(?=$|[^\p{L}])/iu;
|
|
12
|
+
const FEMALE_PATRONYMIC_RE = /(?:^|[^\p{L}])\p{L}[\p{L}ёЁ-]{2,}(?:овна|евна|ична|инична)(?=$|[^\p{L}])/iu;
|
|
13
|
+
const MALE_PATRONYMIC_RE = /(?:^|[^\p{L}])\p{L}[\p{L}ёЁ-]{2,}(?:ович|евич|ич)(?=$|[^\p{L}])/iu;
|
|
14
|
+
const NAME_LINE_RE = /^[\p{L}'’ʻʼ‘`-]+(?:\s+[\p{L}'’ʻʼ‘`-]+){1,5}$/u;
|
|
15
|
+
const NON_NAME_LINE_RE = /(?:^|\s)(?:работ\p{L}*|робот\p{L}*|онлайн|удал[её]н\p{L}*|remote|job|role|ish|любая|любую|подработ\p{L}*)(?=$|\s)/iu;
|
|
16
|
+
const NO_EXPERIENCE_RE = /без опыта|нет опыта|без досвіду|немає досвіду|no experience|fără experiență|tajribasiz|tajriba(?:m)?\s+yo(?:'|’)q|ish tajribasi talab qilinmaydi/iu;
|
|
17
|
+
const EXPERIENCE_VALUE_RE = /(?<![\d])(\d+(?:[.,]\d+)?)\s*(?:лет|год(?:а|ов)?|рок(?:и|ів)?|years?|ani|an|yil)(?![\p{L}\p{N}])/iu;
|
|
18
|
+
const AGE_VALUE_RE = /(?<![\d])(\d{2})\s*(?:лет|год(?:а)?|рок(?:и|ів)?|years?|ani|an|yil)(?![\p{L}\p{N}])/iu;
|
|
19
|
+
|
|
20
|
+
export function extractCandidateGender(value) {
|
|
21
|
+
const text = String(value || '');
|
|
22
|
+
if (EXPLICIT_FEMALE_RE.test(text)) return 'female';
|
|
23
|
+
if (EXPLICIT_MALE_RE.test(text)) return 'male';
|
|
24
|
+
if (FEMALE_LINEAGE_RE.test(text)) return 'female';
|
|
25
|
+
if (MALE_LINEAGE_RE.test(text)) return 'male';
|
|
26
|
+
const nameLine = text.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || '';
|
|
27
|
+
if (!NAME_LINE_RE.test(nameLine) || NON_NAME_LINE_RE.test(nameLine)) return undefined;
|
|
28
|
+
if (FEMALE_PATRONYMIC_RE.test(nameLine)) return 'female';
|
|
29
|
+
if (MALE_PATRONYMIC_RE.test(nameLine)) return 'male';
|
|
30
|
+
if (FEMALE_SURNAME_RE.test(nameLine) || FEMALE_LATIN_SURNAME_RE.test(nameLine)) return 'female';
|
|
31
|
+
if (MALE_SURNAME_RE.test(nameLine) || MALE_LATIN_SURNAME_RE.test(nameLine)) return 'male';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractCandidateName(value) {
|
|
36
|
+
const text = String(value || '');
|
|
37
|
+
const match = text.match(/(?:^|\n)[^\p{L}\p{N}\n]{0,10}(?:xodim|hodim|nomzod|candidate|фио|ф\.и\.о\.?|f\.?\s*i\.?\s*sh\.?|піб|full name|name|имя|ім(?:ʼ|')я|fio|ism(?:i|im)?(?:\s*[-–—]\s*(?:familya|familiya))?|familya|familiya)\s*[:—-]\s*([^\n]{2,100})/iu);
|
|
38
|
+
return (match?.[1] || '')
|
|
39
|
+
.split(/\s*[▪▫◾◽📚🕑🌐💰📞🇺🇿]\s*|\s+(?=(?:tug['’ʻʼ‘`]?ilgan|yashash|ma['’ʻʼ‘`]?lumoti|avvalgi|ish\s+staji|so['’ʻʼ‘`]?ralgan)\b)/iu)[0]
|
|
40
|
+
.trim().replace(/\s{2,}/g, ' ').slice(0, 100);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function extractCandidateAge(value, now = new Date()) {
|
|
44
|
+
const text = String(value || '');
|
|
45
|
+
const patterns = [
|
|
46
|
+
/(?:возраст|вік|age|yosh|yoshi|vârsta)\s*[:—-]?\s*(\d{1,2})/iu,
|
|
47
|
+
/(?:yoshim|yoshm)\s*(\d{1,2})\s*da\b/iu,
|
|
48
|
+
/(?:мне|мені)\s+(\d{1,2})\s*(?:лет|рок(?:и|ів)?)/iu,
|
|
49
|
+
AGE_VALUE_RE,
|
|
50
|
+
];
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
const match = text.match(pattern);
|
|
53
|
+
const age = match ? Number(match[1]) : Number.NaN;
|
|
54
|
+
if (Number.isFinite(age) && age >= 14 && age <= 90) return age;
|
|
55
|
+
}
|
|
56
|
+
const born = text.match(/(?:tug(?:['’‘])ilgan\s+yili|tug(?:['’‘])ilgan\s+sana(?:si)?|год\s+рождения|дата\s+рождения|рік\s+народження|дата\s+народження|birth\s+(?:year|date))\s*[:—-]?\s*(?:(\d{1,2})[./-](\d{1,2})[./-])?((?:19|20)\d{2})/iu);
|
|
57
|
+
if (!born) return null;
|
|
58
|
+
const year = Number(born[3]);
|
|
59
|
+
let age = now.getUTCFullYear() - year;
|
|
60
|
+
if (born[1] && born[2]) {
|
|
61
|
+
const month = Number(born[2]);
|
|
62
|
+
const day = Number(born[1]);
|
|
63
|
+
if (now.getUTCMonth() + 1 < month || (now.getUTCMonth() + 1 === month && now.getUTCDate() < day)) age -= 1;
|
|
64
|
+
}
|
|
65
|
+
return age >= 14 && age <= 90 ? age : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseCandidateExperienceValue(value) {
|
|
69
|
+
const text = String(value || '');
|
|
70
|
+
if (NO_EXPERIENCE_RE.test(text)) return 0;
|
|
71
|
+
const match = text.match(EXPERIENCE_VALUE_RE);
|
|
72
|
+
return match ? Number(match[1].replace(',', '.')) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function extractCandidateExperienceYears(value) {
|
|
76
|
+
const text = String(value || '');
|
|
77
|
+
if (NO_EXPERIENCE_RE.test(text)) return 0;
|
|
78
|
+
const match = text.match(/(?:опыт(?: работы)?|стаж|досвід(?: роботи)?|experience|experiență|tajriba\p{L}*|ish tajribasi)[^\d]{0,30}(\d+(?:[.,]\d+)?)\s*(?:лет|год(?:а|ов)?|рок(?:и|ів)?|years?|ani|an|yil)/iu)
|
|
79
|
+
|| text.match(/(?<![\d])(\d+(?:[.,]\d+)?)\s*(?:лет|год(?:а|ов)?|рок(?:и|ів)?|years?|ani|an|yil)(?![\p{L}\p{N}])[^\n]{0,30}(?:опыт|стаж|досвід|experience|experiență|tajriba)/iu);
|
|
80
|
+
return match ? Number(match[1].replace(',', '.')) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractCandidateContacts(value, country = '') {
|
|
84
|
+
const text = String(value || '');
|
|
85
|
+
const countryHint = countryPhoneHint(country) || undefined;
|
|
86
|
+
const parsedPhone = parsePhoneNumbers(text, { countryHint, includePossible: true })[0];
|
|
87
|
+
const broadPhone = parsedPhone ? null : findPhoneLikeSpans(text)[0];
|
|
88
|
+
const phone = parsedPhone?.number || broadPhone?.raw || undefined;
|
|
89
|
+
const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0];
|
|
90
|
+
const telegram = findTelegramContacts(text)[0]?.handle;
|
|
91
|
+
return Object.freeze({ ...(phone ? { phone } : {}), ...(email ? { email } : {}), ...(telegram ? { telegram } : {}) });
|
|
92
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type HiringMessageKind = 'vacancy' | 'candidate' | 'vacancy_digest' | 'recruitment_ad' | 'course' | 'job_service' | 'closed_vacancy' | 'spam' | 'unknown';
|
|
2
|
+
export type ContextualLanguage = Readonly<{
|
|
3
|
+
language: string;
|
|
4
|
+
name: string;
|
|
5
|
+
relation: 'required' | 'preferred' | 'notRequired' | 'candidateHas' | null;
|
|
6
|
+
level: string | null;
|
|
7
|
+
cefr: 'A1' | 'A2' | 'B1' | 'B2' | 'C1' | 'C2' | null;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
}>;
|
|
11
|
+
export type HiringContextResult = Readonly<{
|
|
12
|
+
kind: HiringMessageKind | string;
|
|
13
|
+
sections: Readonly<Record<string, string>>;
|
|
14
|
+
profession: Readonly<Record<string, unknown>>;
|
|
15
|
+
languages: readonly ContextualLanguage[];
|
|
16
|
+
requirementRelations: readonly string[];
|
|
17
|
+
hasResponsibilities: boolean;
|
|
18
|
+
hasOfferSection: boolean;
|
|
19
|
+
contacts: readonly string[];
|
|
20
|
+
application: readonly string[];
|
|
21
|
+
companyContext: readonly string[];
|
|
22
|
+
locationContext: readonly string[];
|
|
23
|
+
workAuthorization: readonly string[];
|
|
24
|
+
availability: readonly string[];
|
|
25
|
+
travel: string | null;
|
|
26
|
+
relocation: string | null;
|
|
27
|
+
bonuses: readonly string[];
|
|
28
|
+
benefits: readonly string[];
|
|
29
|
+
contracts: readonly string[];
|
|
30
|
+
openingCount: number | null;
|
|
31
|
+
vacancyStatus: string | null;
|
|
32
|
+
candidateStatus: string | null;
|
|
33
|
+
candidatePreferences: readonly string[];
|
|
34
|
+
employmentHistoryContext: readonly string[];
|
|
35
|
+
educationContext: string | null;
|
|
36
|
+
driverContext: readonly string[];
|
|
37
|
+
noticePeriod: Readonly<{ value: number; unit: 'day' | 'week' }> | null;
|
|
38
|
+
}>;
|
|
39
|
+
export const REQUIREMENT_TERMS: readonly unknown[];
|
|
40
|
+
export const RESPONSIBILITY_TERMS: readonly unknown[];
|
|
41
|
+
export const OFFER_TERMS: readonly unknown[];
|
|
42
|
+
export const CONTACT_TERMS: readonly unknown[];
|
|
43
|
+
export const APPLICATION_TERMS: readonly unknown[];
|
|
44
|
+
export const COMPANY_TERMS: readonly unknown[];
|
|
45
|
+
export const LOCATION_CONTEXT_TERMS: readonly unknown[];
|
|
46
|
+
export const WORK_AUTHORIZATION_TERMS: readonly unknown[];
|
|
47
|
+
export const HIRING_AVAILABILITY_TERMS: readonly unknown[];
|
|
48
|
+
export const TRAVEL_TERMS: readonly unknown[];
|
|
49
|
+
export const RELOCATION_CONTEXT_TERMS: readonly unknown[];
|
|
50
|
+
export const BONUS_TERMS: readonly unknown[];
|
|
51
|
+
export const CONTRACT_CONTEXT_TERMS: readonly unknown[];
|
|
52
|
+
export const VACANCY_STATUS_TERMS: readonly unknown[];
|
|
53
|
+
export const CANDIDATE_STATUS_TERMS: readonly unknown[];
|
|
54
|
+
export const CANDIDATE_PREFERENCE_TERMS: readonly unknown[];
|
|
55
|
+
export const EMPLOYMENT_HISTORY_TERMS: readonly unknown[];
|
|
56
|
+
export const EDUCATION_CONTEXT_TERMS: readonly unknown[];
|
|
57
|
+
export const DRIVER_CONTEXT_TERMS: readonly unknown[];
|
|
58
|
+
export const HIRING_SECTION_MARKERS: Readonly<Record<string, unknown>>;
|
|
59
|
+
export const HIRING_NON_CONTENT_TERMS: readonly unknown[];
|
|
60
|
+
export function splitHiringSections(value: unknown): Readonly<Record<string, string>>;
|
|
61
|
+
export function parseLanguageContext(value: unknown, options?: { mode?: 'vacancy' | 'candidate' | null }): readonly ContextualLanguage[];
|
|
62
|
+
export function resolveProfessionContext(value: unknown, options?: { mode?: 'vacancy' | 'candidate' | null; title?: string }): Readonly<Record<string, unknown>>;
|
|
63
|
+
export function classifyHiringMessage(value: unknown): HiringMessageKind;
|
|
64
|
+
export function parseHiringContext(value: unknown, options?: { mode?: 'vacancy' | 'candidate' | null; title?: string }): HiringContextResult;
|