@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/index.d.ts +375 -0
  4. package/package.json +75 -0
  5. package/src/central-asia-locations.js +211 -0
  6. package/src/central-asia.js +233 -0
  7. package/src/contact.d.ts +44 -0
  8. package/src/contact.js +159 -0
  9. package/src/countries.js +175 -0
  10. package/src/country-context.d.ts +10 -0
  11. package/src/country-context.js +22 -0
  12. package/src/currency.d.ts +14 -0
  13. package/src/currency.js +66 -0
  14. package/src/geo.js +169 -0
  15. package/src/geography-central-asia.js +42 -0
  16. package/src/geography-detection.d.ts +14 -0
  17. package/src/geography-detection.js +64 -0
  18. package/src/geography-display.d.ts +13 -0
  19. package/src/geography-display.js +113 -0
  20. package/src/geography.js +210 -0
  21. package/src/hiring-advanced.js +147 -0
  22. package/src/hiring-candidate-fields.d.ts +9 -0
  23. package/src/hiring-candidate-fields.js +92 -0
  24. package/src/hiring-context.d.ts +64 -0
  25. package/src/hiring-context.js +412 -0
  26. package/src/hiring-language-extensions.d.ts +19 -0
  27. package/src/hiring-language-extensions.js +139 -0
  28. package/src/hiring-languages.js +135 -0
  29. package/src/hiring-location-fields.d.ts +3 -0
  30. package/src/hiring-location-fields.js +53 -0
  31. package/src/hiring-professions-ro.js +191 -0
  32. package/src/hiring-professions.js +437 -0
  33. package/src/hiring-requirements.d.ts +16 -0
  34. package/src/hiring-requirements.js +170 -0
  35. package/src/hiring-salary-context.d.ts +27 -0
  36. package/src/hiring-salary-context.js +76 -0
  37. package/src/hiring-semantics.d.ts +29 -0
  38. package/src/hiring-semantics.js +218 -0
  39. package/src/hiring-skills.d.ts +21 -0
  40. package/src/hiring-skills.js +315 -0
  41. package/src/hiring-source-aliases.d.ts +32 -0
  42. package/src/hiring-source-aliases.js +186 -0
  43. package/src/hiring-source-semantics.d.ts +31 -0
  44. package/src/hiring-source-semantics.js +293 -0
  45. package/src/hiring-temporal.d.ts +15 -0
  46. package/src/hiring-temporal.js +67 -0
  47. package/src/hiring-vacancy-fields.d.ts +5 -0
  48. package/src/hiring-vacancy-fields.js +23 -0
  49. package/src/hiring-work-semantics.d.ts +34 -0
  50. package/src/hiring-work-semantics.js +72 -0
  51. package/src/hiring.js +180 -0
  52. package/src/housing-address.d.ts +18 -0
  53. package/src/housing-address.js +210 -0
  54. package/src/housing-context.d.ts +36 -0
  55. package/src/housing-context.js +183 -0
  56. package/src/housing-features.js +30 -0
  57. package/src/housing-intent.d.ts +9 -0
  58. package/src/housing-intent.js +132 -0
  59. package/src/housing-listing-fields.js +131 -0
  60. package/src/housing-money.d.ts +7 -0
  61. package/src/housing-money.js +102 -0
  62. package/src/housing-source-aliases.d.ts +8 -0
  63. package/src/housing-source-aliases.js +51 -0
  64. package/src/housing-structured.d.ts +55 -0
  65. package/src/housing-structured.js +219 -0
  66. package/src/housing.js +200 -0
  67. package/src/index.js +44 -0
  68. package/src/kz-location-extensions.js +307 -0
  69. package/src/landmarks.js +21 -0
  70. package/src/lexicon-core.js +58 -0
  71. package/src/location-merge.js +106 -0
  72. package/src/locations.js +456 -0
  73. package/src/money-core.d.ts +10 -0
  74. package/src/money-core.js +81 -0
  75. package/src/money-lexicon.d.ts +5 -0
  76. package/src/money-lexicon.js +79 -0
  77. package/src/money.js +154 -0
  78. package/src/normalization.js +384 -0
  79. package/src/odesa-metropolitan.js +145 -0
  80. package/src/romania-geography.js +47 -0
  81. package/src/tashkent-colloquial.js +72 -0
  82. package/src/tashkent-housing-geography.d.ts +38 -0
  83. package/src/tashkent-housing-geography.js +211 -0
  84. package/src/tashkent-pois.js +215 -0
  85. package/src/tashkent-residential-complexes.js +272 -0
  86. package/src/ua-location-extensions-major.js +118 -0
  87. package/src/ua-location-extensions-metro.js +9 -0
  88. package/src/ua-location-extensions-regional.js +246 -0
  89. package/src/ua-secondary-cities.d.ts +2 -0
  90. package/src/ua-secondary-cities.js +57 -0
  91. package/src/ukraine.js +75 -0
  92. package/src/uz-location-extensions.js +259 -0
@@ -0,0 +1,170 @@
1
+ import { parseExperience } from './hiring-advanced.js';
2
+
3
+ export const SENIORITY_RANK = Object.freeze({
4
+ intern: 0,
5
+ junior: 1,
6
+ middle: 2,
7
+ senior: 3,
8
+ staff: 4,
9
+ lead: 4,
10
+ manager: 4,
11
+ principal: 5,
12
+ director: 6,
13
+ });
14
+
15
+ export function detectHiringSeniority(value) {
16
+ const text = String(value || '');
17
+ const target = /\b(?:looking|searching)\s+for\s+(?:an?\s+)?(?:a\s+)?(intern|junior|middle|mid|senior|staff|principal|lead)\b/i.exec(text)?.[1];
18
+ const normalize = (item) => item.toLowerCase() === 'mid' ? 'middle' : item.toLowerCase();
19
+ if (target) return normalize(target);
20
+ const checks = [
21
+ ['director', /\b(?:engineering\s+)?director\b|директор/i],
22
+ ['principal', /\bprincipal\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
23
+ ['staff', /\bstaff\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
24
+ ['lead', /\b(?:team\s*lead|tech\s*lead|lead\s+(?:engineer|developer|frontend|backend))\b|тимлид|техлид|ведущ\w*/i],
25
+ ['manager', /\bengineering manager\b|руководител/i],
26
+ ['senior', /\bsenior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|сеньор|старш\w*\s+(?:разработ|инженер)/i],
27
+ ['middle', /\b(?:middle|mid[- ]?level)\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|мидл/i],
28
+ ['junior', /\bjunior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|джун\w*|младш\w*/i],
29
+ ['intern', /\b(?:intern|trainee)\b|стаж[ёе]р|стажир/i],
30
+ ];
31
+ return checks.find(([, pattern]) => pattern.test(text))?.[0] || null;
32
+ }
33
+
34
+ export function detectDegreeLevel(value) {
35
+ const text = String(value || '');
36
+ if (/\b(?:ph\.?d\.?|doctorate|doctoral degree)\b|доктор(?:ская| наук)/i.test(text)) return 'doctorate';
37
+ if (/master['’]?s degree|master degree|магистр|магістр/i.test(text)) return 'master';
38
+ if (/bachelor['’]?s degree|bachelor degree|бакалавр/i.test(text)) return 'bachelor';
39
+ if (/secondary education|среднее образование|середня освіта/i.test(text)) return 'secondary';
40
+ return null;
41
+ }
42
+
43
+ export function detectDegreeFields(value) {
44
+ const text = String(value || '');
45
+ const fields = [];
46
+ if (/computer science|computer engineering|software engineering|information technology|informatics|інформатик|информатик/i.test(text)) fields.push('computer_science');
47
+ if (/\bengineering\b|инженерн|інженерн/i.test(text)) fields.push('engineering');
48
+ if (/\b(?:civil\s+)?law\b|legal studies|юридич|юриспруд|право\b/i.test(text)) fields.push('law');
49
+ if (/forensic|criminal investigation|криминалист|криміналіст|следствен|слідч/i.test(text)) fields.push('forensics');
50
+ if (/business|economics|finance|management|эконом|економ/i.test(text)) fields.push('business');
51
+ return Object.freeze([...new Set(fields)]);
52
+ }
53
+
54
+ export function extractRequiredExperienceYears(value) {
55
+ const parsed = parseExperience(value);
56
+ return typeof parsed?.minYears === 'number' ? parsed.minYears : null;
57
+ }
58
+
59
+ export function hasUsWorkAuthorization(value) {
60
+ const text = String(value || '');
61
+ return /\b(?:u\.?s\.?|united states)\s+citizen\b|\bgreen card\b|\bpermanent resident\b|\bemployment authorization document\b|\bEAD\b|\bauthoriz\w+\s+to\s+work\s+in\s+(?:the\s+)?(?:u\.?s\.?|united states)(?:[^.!?]{0,50}\bwithout\s+sponsorship\b)?|\bno\s+(?:visa\s+)?sponsorship\s+required\b/i.test(text);
62
+ }
63
+
64
+ export function requiresUsSponsorship(value) {
65
+ const text = String(value || '');
66
+ if (hasUsWorkAuthorization(text)) return false;
67
+ if (/\b(?:require|requires|requiring|need|needs|seeking)\b[^.!?]{0,60}\b(?:visa|employment)\s+sponsorship\b|\bneed\s+(?:an?\s+)?(?:h-?1b|work visa)\b/i.test(text)) return true;
68
+ const citizenship = /\bcitizenship\s*[:\-]\s*([^\n|,;]{2,45})/i.exec(text)?.[1];
69
+ if (citizenship && !/^\s*(?:u\.?s\.?a?|united states|american)\b/i.test(citizenship)) return true;
70
+ return null;
71
+ }
72
+
73
+ export function isNoSponsorshipRequirement(value) {
74
+ const text = String(value || '');
75
+ return /(?:\bno\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\b(?:will\s+not|cannot|can't|unable\s+to|not\s+able\s+to)\s+sponsor\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:current\s+or\s+future\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,100}\bwithout\s+(?:current\s+or\s+future\s+)?sponsorship\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bmay\s+not\s+be\s+able\s+to\b[^\n!?]{0,450}\b(?:sponsor|support|provide)\b[^\n!?]{0,180}\bsponsorship\b|\b(?:will|can|may)\s+not\b[^\n!?]{0,220}\b(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/i.test(text);
76
+ }
77
+
78
+ const REQUIRED_MARKER_RE = /\b(requirements?|qualifications?|minimum qualifications?|required skills?|must[- ]?have|you have|what (?:we|you) (?:are looking for|need|bring)|you(?:'|’)ll need|who you are|ideal candidate|what makes you a fit)\b|требован|квалификац|обязательн|необходим(?:о|ые|ый)|что мы (?:жд[её]м|ожидаем)|кого мы ищем|вимог|кваліфікац|обов['’]?язков|необхідн|кого ми шукаємо/i;
79
+ const OPTIONAL_MARKER_RE = /\b(nice to have|preferred qualifications?|preferred skills?|bonus points?|would be a plus|plus if|desirable)\b|желательн|будет плюсом|буде плюсом|преимуществ|бажан/i;
80
+ const HARD_REQUIREMENT_RE = /\b(must|need to|required|proficien(?:t|cy)|expertise in|experience (?:with|in)|knowledge of|familiarity with|hands[- ]on)\b|обязател|требуется|необходим|знание|опыт (?:с|в)|владение|умение|потрібн|необхідн|досвід (?:з|у|в)|знання/i;
81
+ const NOISE_RE = /\b(equal opportunity|eeo|diversity and inclusion|reasonable accommodation|candidate privacy|privacy notice|background check|recruit(?:ment|ing) process|talent acquisition team|compensation range|pay transparency)\b|процесс найма|процес найму|политик[аи] конфиденциальности|політик[аи] конфіденційності/i;
82
+ const SECTION_BREAK_RE = /\b(what we offer|benefits|perks|about us|about the company|our company|compensation|salary|responsibilities|what you(?:'|’)ll do|your role)\b|что мы предлагаем|условия работы|о компании|про компанію|обязанности|обов['’]?язки/i;
83
+
84
+ export function bucketVacancyText(value) {
85
+ const segments = String(value || '').replace(/[•●▪◦·]/g, '. ').split(/\n+|(?<=[.!?;])\s+/).map((part) => part.trim()).filter(Boolean);
86
+ const buckets = { required: [], optional: [], context: [], noise: [] };
87
+ let active = null;
88
+ let ttl = 0;
89
+ for (const segment of segments) {
90
+ if (NOISE_RE.test(segment)) { buckets.noise.push(segment); active = null; ttl = 0; continue; }
91
+ buckets.context.push(segment);
92
+ if (OPTIONAL_MARKER_RE.test(segment)) { active = 'optional'; ttl = 6; buckets.optional.push(segment); continue; }
93
+ if (REQUIRED_MARKER_RE.test(segment)) { active = 'required'; ttl = 8; buckets.required.push(segment); continue; }
94
+ if (SECTION_BREAK_RE.test(segment)) { active = null; ttl = 0; }
95
+ if (HARD_REQUIREMENT_RE.test(segment)) { buckets.required.push(segment); continue; }
96
+ if (active && ttl > 0) { buckets[active].push(segment); ttl -= 1; }
97
+ }
98
+ return Object.freeze({ required: buckets.required.join(' '), optional: buckets.optional.join(' '), context: buckets.context.join(' '), noise: buckets.noise.join(' ') });
99
+ }
100
+
101
+ const CV_SECTION_HEADING_RE = /^\s*(profile|professional profile|summary|professional summary|about me|work experience|professional experience|experience|employment|employment history|projects?|pet projects?|hobbies|skills|technical skills|tech stack|education|languages?|contact|additional information)\s*:?[\s]*$/i;
102
+
103
+ export function classifyCvSectionHeading(value) {
104
+ const heading = CV_SECTION_HEADING_RE.exec(String(value || '').trim())?.[1];
105
+ if (!heading) return null;
106
+ const normalized = heading.toLowerCase();
107
+ if (/work experience|professional experience|^experience$|employment/.test(normalized)) return 'experience';
108
+ if (/project|hobbies/.test(normalized)) return 'projects';
109
+ if (/profile|summary|about me/.test(normalized)) return 'profile';
110
+ if (/skills|tech stack/.test(normalized)) return 'skills';
111
+ if (/education/.test(normalized)) return 'education';
112
+ return 'other';
113
+ }
114
+
115
+ export function extractCvSection(value, wanted) {
116
+ const lines = String(value || '').replace(/\r/g, '').split('\n');
117
+ const collected = [];
118
+ let section = 'other';
119
+ let sawHeading = false;
120
+ for (const line of lines) {
121
+ const trimmed = line.trim();
122
+ const nextSection = classifyCvSectionHeading(trimmed);
123
+ if (nextSection) {
124
+ section = nextSection;
125
+ sawHeading = true;
126
+ continue;
127
+ }
128
+ if (section === wanted && trimmed) collected.push(trimmed);
129
+ }
130
+ return sawHeading ? collected.join('\n') : '';
131
+ }
132
+
133
+ function monthIndex(year, month = 1) {
134
+ return year * 12 + Math.max(1, Math.min(12, month)) - 1;
135
+ }
136
+
137
+ export function extractCvExperienceYears(value, referenceDate = new Date()) {
138
+ const raw = String(value || '');
139
+ const experienceSection = extractCvSection(raw, 'experience');
140
+ const datedSource = experienceSection || raw;
141
+ const intervals = [];
142
+ const ranges = /\b(19\d{2}|20\d{2})(?:[-/.](0?[1-9]|1[0-2]))?\s*(?:-|–|—|to)\s*(?:(present|current|now)|((?:19|20)\d{2})(?:[-/.](0?[1-9]|1[0-2]))?)/gi;
143
+ for (const match of datedSource.matchAll(ranges)) {
144
+ const startYear = Number(match[1]);
145
+ const startMonth = Number(match[2] || 1);
146
+ const endYear = match[3] ? referenceDate.getFullYear() : Number(match[4]);
147
+ const endMonth = match[3] ? referenceDate.getMonth() + 1 : Number(match[5] || 12);
148
+ if (!startYear || !endYear) continue;
149
+ const start = monthIndex(startYear, startMonth);
150
+ const end = monthIndex(endYear, endMonth);
151
+ if (end >= start && end - start <= 12 * 50) intervals.push([start, end]);
152
+ }
153
+ intervals.sort((a, b) => a[0] - b[0]);
154
+ const merged = [];
155
+ for (const interval of intervals) {
156
+ const last = merged[merged.length - 1];
157
+ if (!last || interval[0] > last[1] + 1) merged.push([...interval]);
158
+ else last[1] = Math.max(last[1], interval[1]);
159
+ }
160
+ const datedMonths = merged.reduce((sum, [start, end]) => sum + end - start + 1, 0);
161
+ const datedYears = datedMonths ? datedMonths / 12 : 0;
162
+ let explicitYears = 0;
163
+ const explicit = /\b(?:over|more than|at least|about|approximately|approx\.?|around)?\s*(\d{1,2}(?:[.,]\d)?)\+?\s*(?:years?|yrs?)\s+(?:of\s+)?(?:hands[- ]on\s+|professional\s+|commercial\s+)?experience\b/gi;
164
+ for (const match of raw.matchAll(explicit)) {
165
+ const years = Number(match[1]?.replace(',', '.'));
166
+ if (Number.isFinite(years) && years >= 0 && years <= 50) explicitYears = Math.max(explicitYears, years);
167
+ }
168
+ const result = Math.max(datedYears, explicitYears);
169
+ return result > 0 ? Math.round(result * 10) / 10 : null;
170
+ }
@@ -0,0 +1,27 @@
1
+ export type SalaryCurrencySource = 'explicit' | 'country-default' | 'language-default' | 'unknown';
2
+
3
+ export interface HiringSalaryContextOptions {
4
+ country?: string | null;
5
+ location?: string | null;
6
+ currencyFallback?: 'country' | 'language';
7
+ }
8
+
9
+ export interface ParsedHiringSalaryWithContext {
10
+ min: number | null;
11
+ max: number | null;
12
+ currency: string | null;
13
+ period: string | null;
14
+ negotiable: boolean;
15
+ gross: boolean | null;
16
+ approximate: boolean;
17
+ currencySource: SalaryCurrencySource;
18
+ currencyCountry: string | null;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export const COUNTRY_DEFAULT_CURRENCIES: Readonly<Record<string, string>>;
23
+ export function defaultCurrencyForCountry(value: unknown): string | null;
24
+ export function parseHiringSalaryWithContext(
25
+ value: unknown,
26
+ options?: HiringSalaryContextOptions,
27
+ ): ParsedHiringSalaryWithContext | null;
@@ -0,0 +1,76 @@
1
+ import { detectCountryCodeFromText } from './geography-detection.js';
2
+ import { parseSalary } from './money.js';
3
+
4
+ /**
5
+ * Default local currencies used only as an opt-in fallback when salary text
6
+ * contains an amount but no explicit currency. Explicit currency always wins.
7
+ */
8
+ export const COUNTRY_DEFAULT_CURRENCIES = Object.freeze({
9
+ US: 'USD',
10
+ GB: 'GBP',
11
+ UA: 'UAH',
12
+ UZ: 'UZS',
13
+ KZ: 'KZT',
14
+ KG: 'KGS',
15
+ RO: 'RON',
16
+ PL: 'PLN',
17
+ TR: 'TRY',
18
+ GE: 'GEL',
19
+ });
20
+
21
+ export function defaultCurrencyForCountry(value) {
22
+ const country = detectCountryCodeFromText(value);
23
+ return country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
24
+ }
25
+
26
+ function contextCountry(options) {
27
+ return detectCountryCodeFromText(options.country)
28
+ || detectCountryCodeFromText(options.location)
29
+ || null;
30
+ }
31
+
32
+ function languageDefaultCountry(value) {
33
+ const text = String(value || '');
34
+ // Use only language signals that are specific enough to avoid turning generic
35
+ // Russian/English salary text into a local currency guess. Uzbek and Kazakh
36
+ // salary/period vocabulary is deterministic enough for this fallback.
37
+ if (/(?:^|[^\p{L}\p{N}])(?:oylik|oyiga|maosh|ish\s+haqi|ойлик|ойига|маош|иш\s+ҳақи)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'UZ';
38
+ if (/(?:^|[^\p{L}\p{N}])(?:жалақы|еңбекақы|айлық|айына)(?=$|[^\p{L}\p{N}])/iu.test(text)) return 'KZ';
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Parse salary text and optionally infer a missing currency from geography or
44
+ * unambiguous local salary-language markers.
45
+ *
46
+ * The fallback is deliberately opt-in. A bare number must never silently become
47
+ * USD merely because a consumer happens to normalize all salaries to USD later.
48
+ * Provenance is returned in `currencySource` so consumers can distinguish an
49
+ * explicit currency from a contextual fallback.
50
+ */
51
+ export function parseHiringSalaryWithContext(value, options = {}) {
52
+ const parsed = parseSalary(value);
53
+ if (!parsed) return null;
54
+
55
+ if (parsed.currency) {
56
+ return Object.freeze({
57
+ ...parsed,
58
+ currencySource: 'explicit',
59
+ currencyCountry: null,
60
+ });
61
+ }
62
+
63
+ let country = null;
64
+ if (options.currencyFallback === 'country') country = contextCountry(options);
65
+ else if (options.currencyFallback === 'language') country = languageDefaultCountry(value);
66
+
67
+ const currency = country ? COUNTRY_DEFAULT_CURRENCIES[country] || null : null;
68
+ return Object.freeze({
69
+ ...parsed,
70
+ currency,
71
+ currencySource: currency
72
+ ? options.currencyFallback === 'language' ? 'language-default' : 'country-default'
73
+ : 'unknown',
74
+ currencyCountry: currency ? country : null,
75
+ });
76
+ }
@@ -0,0 +1,29 @@
1
+ import type { DegreeLevel } from './hiring-requirements.js';
2
+
3
+ export type CandidateFeatureCode = 'student' | 'parentalLeave' | 'noExperience' | 'partTime' | 'nightShift' | 'openToRelocation';
4
+ export type HiringScopeCode = 'architecture' | 'leadership' | 'mentoring' | 'scale' | 'ownership';
5
+
6
+ export function isCandidateNonTargetContext(value: unknown): boolean;
7
+ export function extractCandidateTargetContext(value: unknown): string;
8
+ export function extractCandidateSkillField(value: unknown): string | null;
9
+ export function extractCandidateRoleField(value: unknown): string | null;
10
+ export function extractCandidateGoalField(value: unknown): string | null;
11
+ export function extractCandidateLocationField(value: unknown): string | null;
12
+ export function isCandidateStatusOnly(value: unknown): boolean;
13
+ export function isFlexibleCandidateRole(value: unknown): boolean;
14
+ export function isCandidateNonRoleValue(value: unknown): boolean;
15
+ export function extractCandidateGoalRole(value: unknown): string | null;
16
+ export function extractCandidateWorkHistory(value: unknown): string;
17
+ export function detectCandidateFeatureCodes(value: unknown): readonly CandidateFeatureCode[];
18
+ export function extractCandidateContactHours(value: unknown): string | null;
19
+ export function extractCandidateSalaryField(value: unknown): string | null;
20
+ export function detectCandidateRelocationPreference(value: unknown): boolean | null;
21
+ export function detectCandidateRemotePreference(value: unknown): boolean | null;
22
+ export function detectCandidateProfessionLabels(targetValue: unknown, skillValue?: unknown): readonly string[];
23
+ export function detectManagementRole(title: unknown, value?: unknown): true | null;
24
+ export function detectHiringScopeSignals(value: unknown, options?: { mode?: 'vacancy' | 'candidate' }): readonly HiringScopeCode[];
25
+ export function detectDegreeRequirement(value: unknown): Readonly<{
26
+ level?: DegreeLevel;
27
+ field?: string;
28
+ equivalentExperience: boolean;
29
+ }>;
@@ -0,0 +1,218 @@
1
+ import { detectDegreeFields, detectDegreeLevel } from './hiring-requirements.js';
2
+ import { matchExtendedProfessions } from './hiring-source-aliases.js';
3
+
4
+ const NON_TARGET_CONTEXT_RE = /(?:опыт\s+работы|досвід\s+роботи|work\s+experience|previous|раньше|ранее|прежде|работал|работала|працював|працювала|worked\s+(?:as|at)|oldin|avval|ishlagan|ishladim|tajriba|диплом|diplom|mutaxassisligim)/iu;
5
+ const TARGET_CONTEXT_RE = /(?:ищу\s+(?:работу|подработку)|шукаю\s+(?:роботу|підробіток)|желаемая\s+(?:должность|работа)|бажана\s+(?:посада|робота)|target\s+role|desired\s+(?:role|position)|looking\s+for\s+(?:a\s+)?(?:job|work)|open\s+to\s+work|menga\s+ish\s+kerak|ish\s+(?:kerak|qidiryapman|qidiraman|izlayapman)|ish\s+joyi\s+kerak|lavozim|kasb|soha|soxa|maqsad(?:im)?)/iu;
6
+
7
+ const SKILLS_FIELD_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,10}(?:skills|навыки|навички|стек|stack|technologies|texnologiya(?:lar)?|ko(?:'|’)nikmalar)\s*[:—-]\s*([^\n]{2,500})/iu;
8
+ const WORK_HISTORY_BLOCK_RE = /(?:^|\n)\s*(?:опыт\s+работы|досвід\s+роботи|work\s+experience|previous\s+(?:jobs?|positions?)|tajriba|ish\s+tajribasi)\s*[:—-]?\s*([\s\S]{1,2600}?)(?=\n\s*(?:навыки|навички|skills|образование|освіта|education|контакт|contact|ожидания|salary|языки|мови|languages)\s*[:—-]|$)/iu;
9
+ const WORK_HISTORY_LINE_RE = /(?:работал[аи]?|працюва(?:в|ла)|worked\s+(?:as|at)|oldin|avval|ishlagan|ishladim|ishlaganman|tajriba(?:m)?\s+bor)/iu;
10
+ const GOAL_ROLE_RE = /\b((?:(?:frontend|front-end|backend|back-end|full[- ]?stack|mobile|android|ios)\s+)?(?:dasturchi|developer|programmer))\s+sifatida\s+(?:ish\s+(?:topish|qidirish|izlash)|ishlash)\b/iu;
11
+ const CONTACT_HOURS_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,10}(?:murojaat\s+qilish\s+vaqti|aloqa\s+vaqti|qo(?:'|’)ng(?:'|’)iroq\s+vaqti|bog(?:'|’)lanish\s+vaqti|время\s+(?:связи|звонков|для\s+связи)|звонить\s+(?:с|в)|contact\s+(?:hours|time)|call\s+time)\s*[:—-]?\s*([^\n]{3,60})/iu;
12
+ const SALARY_FIELD_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,10}(?:narxi?|salary|expected\s+salary|зарплата|зп|бажана\s+зарплата|oylik|maosh|ish\s+haqi)\s*[:—-]\s*([^\n]{1,120})/iu;
13
+ const ROLE_FIELD_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,8}(?:желаемая\s+(?:работа|должность)|бажана\s+(?:робота|посада)|target\s+role|desired\s+(?:role|position)|position|role|должность|посада|lavozim|kasbi|kasb|qidirayotgan\s+kasb|so(?:'|’)ralgan\s+ish\s+turi)\s*[::—-]\s*([^\n]{1,220})/iu;
14
+ const GOAL_FIELD_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,8}(?:maqsad|мақсад|goal|цель|мета)\s*[::—-]\s*([^\n]{1,220})/iu;
15
+ const LOCATION_FIELD_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,8}(?:location|локация|локація|country|страна|країна)\s*[::—-]\s*([^\n]{1,220})/iu;
16
+ const LOCATION_COMPACT_RE = /(?:^|\n)[^\p{L}\p{N}\n]{0,8}(?:location|локация|локація|country|страна|країна)\s+(#?[\p{L}][^\n]{0,120})/iu;
17
+
18
+ const STATUS_ONLY_RE = /^(?:talaba|student|студент(?:ка)?|студент(?:ка)?ка|o(?:'|’)quvchi|учащ(?:ийся|аяся))$/iu;
19
+ const FLEXIBLE_ROLE_RE = /^(?:нет|без)\s+разницы(?:\s+.*)?$|^не\s*важно(?:\s+.*)?$|^farqi\s+yo['’ʻʼ‘`]?q$|^любая\s+(?:работа|занятость)(?:\s+.*)?$/iu;
20
+ const NON_ROLE_RE = /^(?:удал[её]нно|работа\s+на\s+удал[её]н\p{L}*\s+основе|remote|onlayn|online|онлайн|farqi\s+yo['’ʻʼ‘`]?q|bilmaym\p{L}*|ish\s+ker(?:e|a)\s+onlayn|любая\s+(?:работа|занятость)|немає|нет|не\s+указано|not\s+specified)$/iu;
21
+ const REMOTE_NEGATIVE_RE = /(?:onsite|on-site|office\s+only|тільки\s+офіс|только\s+офис|офисн(?:ый|ая)\s+формат|офлайн|без\s+удал[её]нк|удал[её]нк\p{L}*\s+не\s+рассматрива|не\s+рассматрива\p{L}*\s+удал[её]н|remote\s+(?:not|no)|faqat\s+ofis|ofisda\s+ish(?:lash)?)/iu;
22
+ const REMOTE_POSITIVE_RE = /(?:\bremote\b|\bremotely\b|удал[её]н(?:но|ка|ный|ная|н\p{L}*)?|віддален(?:о|а|ий)?|дистанц(?:ионно|ійно)|masofaviy|(?<!\p{L})onlayn(?!\p{L})|online\s+(?:work|job)|онлайн\s+работ)/iu;
23
+
24
+ const FEATURE_RULES = Object.freeze([
25
+ ['student', /\bstudent\b|студент|студентк|talaba/iu],
26
+ ['parentalLeave', /декрет|в\s+декрете|у\s+декреті|maternity\s+leave|parental\s+leave/iu],
27
+ ['noExperience', /без\s+опыта|без\s+досвіду|no\s+experience|tajriba\s+yo(?:'|’)q/iu],
28
+ ['partTime', /подработк|підробіт|part[-\s]?time|неполный\s+день|неповн(?:ий|а)\s+день|yarim\s+stavka/iu],
29
+ ['nightShift', /ночн(?:ая|ую|ой)\s+смен|нічн(?:а|у|ої)\s+змін|night\s+shift|tungi\s+smena/iu],
30
+ ['openToRelocation', /готов\p{L}*\s+к\s+переезду|готов\p{L}*\s+переехать|готов\p{L}*\s+до\s+переїзду|relocat(?:e|ion)|ko(?:'|’)chib\s+o(?:'|’)tish/iu],
31
+ ]);
32
+
33
+ const RELOCATION_NEGATIVE_RE = /не\s+готов\p{L}*\s+к\s+переезду|не\s+розгляда\p{L}*\s+переїзд|not\s+(?:open|ready)\s+to\s+relocat/iu;
34
+ const RELOCATION_POSITIVE_RE = /готов\p{L}*\s+к\s+переезду|готов\p{L}*\s+переехать|готов\p{L}*\s+до\s+переїзду|relocat(?:e|ion)|ko(?:'|’)chib\s+o(?:'|’)tish/iu;
35
+
36
+ const SPECIAL_CANDIDATE_PROFESSIONS = Object.freeze([
37
+ ['HR / Recruiter', /#(?:hr|hrd|hrbp|hrgeneralist|peopleops)\b|\b(?:hr\s+lead|head\s+of\s+hr|people\s+partner|talent\s+operations|кадров\p{L}*\s+аудит)\b/iu],
38
+ ['Sales Manager', /(?:sotuv|savdo)\s+(?:menejer|menejr|menedjer|manager)|\bsales\s+manager\b|менеджер\s+(?:по\s+)?продаж|менеджер\s+з\s+продаж/iu],
39
+ ['Backend Developer', /\bbackend\s+(?:developer|engineer)\b|\bback[- ]?end\s+(?:developer|engineer)\b|backend\s+dasturchi|серверн(?:ый|ий)\s+разработчик/iu],
40
+ ['Frontend Developer', /\bfrontend\s+(?:developer|engineer)\b|\bfront[- ]?end\s+(?:developer|engineer)\b|frontend\s+dasturchi/iu],
41
+ ['AI / ML Engineer', /\b(?:ai|artificial\s+intelligence|machine\s+learning|ml)\s*(?:\/\s*(?:ai|ml))?\s*(?:engineer|developer)\b|\b(?:engineer|developer)\s+(?:ai|ml|machine\s+learning)\b/iu],
42
+ ['Penetration Tester', /\b(?:penetration\s+tester|penetration\s+testing|pentest(?:er|ing)?)\b|пентест(?:ер|инг)?/iu],
43
+ ['Network Administrator', /\b(?:network\s+administrator|network\s+admin)\b|(?:tarmoq|тармоқ)[^\n]{0,70}(?:administrator|admin(?:strator)?i?|администратор)/iu],
44
+ ['System Administrator', /\b(?:system\s+administrator|system\s+admin|sysadmin)\b|(?:tizim|тизим)[^\n]{0,50}(?:administrator|admin(?:strator)?i?|администратор)|системн(?:ый|ий)\s+администратор/iu],
45
+ ['Cybersecurity Specialist', /\b(?:cybersecurity|cyber\s+security|information\s+security)\b|кибербезопасност|кібербезпек|информационн(?:ая|ой)\s+безопасност|інформаційн(?:а|ої)\s+безпек|axborot\s+xavfsizligi/iu],
46
+ ['Data Scientist', /\bdata\s+scientist\b|\bdata\s+science\b|дата\s+саентист/iu],
47
+ ['Data Engineer', /\bdata\s+engineer\b|инженер\s+данных|інженер\s+даних/iu],
48
+ ]);
49
+
50
+ const SCOPE_SIGNALS = Object.freeze([
51
+ Object.freeze({
52
+ code: 'architecture',
53
+ vacancy: /\barchitect(?:ure|ing)?\b|\bsystem design\b|\btechnical roadmap\b|\blong[- ]term solutions?\b/i,
54
+ candidate: /\barchitect(?:ed|ure|ing)?\b|\bsystem design\b|\btechnical roadmap\b|\bsolution architecture\b/i,
55
+ }),
56
+ Object.freeze({
57
+ code: 'leadership',
58
+ vacancy: /\btechnical leader(?:ship)?\b|\bengineering standards\b|\bdrive product vision\b|\blead technical\b/i,
59
+ candidate: /\btechnical leader(?:ship)?\b|\bengineering standards\b|\bled\s+(?:a\s+)?(?:team|project|initiative)\b|\btech(?:nical)? lead\b/i,
60
+ }),
61
+ Object.freeze({
62
+ code: 'mentoring',
63
+ vacancy: /\bmentor(?:ing|ed)?\s+(?:other\s+)?(?:engineers?|developers?|team members?)\b|\bcoach(?:ing|ed)?\s+(?:engineers?|developers?)\b/i,
64
+ candidate: /\bmentor(?:ing|ed)?\s+(?:other\s+)?(?:engineers?|developers?|team members?)\b|\bcoach(?:ing|ed)?\s+(?:engineers?|developers?)\b/i,
65
+ }),
66
+ Object.freeze({
67
+ code: 'scale',
68
+ vacancy: /\b(?:millions?|billions?)\s+of\s+(?:users|people|requests|events)\b|\bat scale\b|\bhigh[- ]scale\b|\blarge[- ]scale\b/i,
69
+ candidate: /\b(?:millions?|billions?)\s+of\s+(?:users|requests|events)\b|\bat scale\b|\bhigh[- ]scale\b|\blarge[- ]scale\b|\bhigh[- ]traffic\b/i,
70
+ }),
71
+ Object.freeze({
72
+ code: 'ownership',
73
+ vacancy: /\bown\s+(?:critical\s+)?(?:features?|systems?|services?|roadmap)\b|\bproduct owner\b|\bdrive product\b/i,
74
+ candidate: /\bown(?:ed|ership)?\s+(?:features?|systems?|services?|roadmap|product)\b|\bproduct owner\b|\bdrove\s+(?:a\s+)?(?:feature|product|initiative)\b/i,
75
+ }),
76
+ ]);
77
+
78
+ const fieldValue = (value, re) => String(value || '').match(re)?.[1]?.trim() || null;
79
+
80
+ export function isCandidateNonTargetContext(value) {
81
+ return NON_TARGET_CONTEXT_RE.test(String(value || ''));
82
+ }
83
+
84
+ export function extractCandidateTargetContext(value) {
85
+ const lines = String(value || '').split('\n').map((line) => line.trim()).filter(Boolean);
86
+ const picked = [];
87
+ for (let i = 0; i < lines.length; i += 1) {
88
+ if (!TARGET_CONTEXT_RE.test(lines[i])) continue;
89
+ for (let offset = 0; offset < 3 && i + offset < lines.length; offset += 1) {
90
+ const line = lines[i + offset];
91
+ if (offset > 0 && NON_TARGET_CONTEXT_RE.test(line)) break;
92
+ picked.push(line);
93
+ }
94
+ }
95
+ return picked.join('\n');
96
+ }
97
+
98
+ export function extractCandidateSkillField(value) {
99
+ return fieldValue(value, SKILLS_FIELD_RE);
100
+ }
101
+
102
+ export function extractCandidateRoleField(value) {
103
+ return fieldValue(value, ROLE_FIELD_RE);
104
+ }
105
+
106
+ export function extractCandidateGoalField(value) {
107
+ return fieldValue(value, GOAL_FIELD_RE);
108
+ }
109
+
110
+ export function extractCandidateLocationField(value) {
111
+ return fieldValue(value, LOCATION_FIELD_RE) || fieldValue(value, LOCATION_COMPACT_RE);
112
+ }
113
+
114
+ export function isCandidateStatusOnly(value) {
115
+ return STATUS_ONLY_RE.test(String(value || '').trim());
116
+ }
117
+
118
+ export function isFlexibleCandidateRole(value) {
119
+ return FLEXIBLE_ROLE_RE.test(String(value || '').trim());
120
+ }
121
+
122
+ export function isCandidateNonRoleValue(value) {
123
+ return NON_ROLE_RE.test(String(value || '').trim());
124
+ }
125
+
126
+ export function extractCandidateGoalRole(value) {
127
+ return String(value || '').match(GOAL_ROLE_RE)?.[1]?.trim() || null;
128
+ }
129
+
130
+ export function extractCandidateWorkHistory(value) {
131
+ const text = String(value || '');
132
+ const explicit = text.match(WORK_HISTORY_BLOCK_RE)?.[1];
133
+ if (explicit) return explicit;
134
+ return text.split(/\n|(?<=[.!?])\s+/u).filter((line) => WORK_HISTORY_LINE_RE.test(line)).join('\n');
135
+ }
136
+
137
+ export function detectCandidateFeatureCodes(value) {
138
+ const text = String(value || '');
139
+ return Object.freeze(FEATURE_RULES.filter(([, re]) => re.test(text)).map(([code]) => code));
140
+ }
141
+
142
+ export function extractCandidateContactHours(value) {
143
+ const raw = String(value || '').match(CONTACT_HOURS_RE)?.[1];
144
+ if (!raw) return null;
145
+ const cleaned = raw.replace(/\s{2,}/g, ' ').replace(/[.;,]+$/, '').trim();
146
+ return /\b24\s*\/\s*7\b|\d{1,2}[:.]\d{2}|\d{1,2}\s*[-–—]\s*\d{1,2}/.test(cleaned)
147
+ ? cleaned.slice(0, 60)
148
+ : null;
149
+ }
150
+
151
+ export function extractCandidateSalaryField(value) {
152
+ return fieldValue(value, SALARY_FIELD_RE);
153
+ }
154
+
155
+ export function detectCandidateRelocationPreference(value) {
156
+ const text = String(value || '');
157
+ if (RELOCATION_NEGATIVE_RE.test(text)) return false;
158
+ if (RELOCATION_POSITIVE_RE.test(text)) return true;
159
+ return null;
160
+ }
161
+
162
+ export function detectCandidateRemotePreference(value) {
163
+ const text = String(value || '');
164
+ if (REMOTE_NEGATIVE_RE.test(text)) return false;
165
+ if (REMOTE_POSITIVE_RE.test(text)) return true;
166
+ return null;
167
+ }
168
+
169
+ export function detectCandidateProfessionLabels(targetValue, skillValue = '') {
170
+ const target = String(targetValue || '');
171
+ const special = SPECIAL_CANDIDATE_PROFESSIONS
172
+ .filter(([, re]) => re.test(target))
173
+ .map(([label]) => label);
174
+ if (special.length) return Object.freeze([...new Set(special)]);
175
+
176
+ const matched = matchExtendedProfessions(target, { limit: 8, allowWeak: true }).map((item) => item.label);
177
+ if (matched.length) return Object.freeze([...new Set(matched)]);
178
+
179
+ const skills = String(skillValue || '');
180
+ if (!skills) return Object.freeze([]);
181
+ if (/\bflutter\b|\bdart\b/iu.test(skills)) return Object.freeze(['Mobile Developer']);
182
+
183
+ const sysadminBase = /\bcisco\b/iu.test(skills)
184
+ && /\b(?:linux|windows\s+server|active\s+directory|network(?:ing)?)\b/iu.test(skills);
185
+ const specializedTools = /\b(?:devops|sre|docker|kubernetes|k8s|terraform|ansible|jenkins|gitlab\s+ci|github\s+actions|ci\/?cd|aws|azure|gcp|ai|ml|mlops|artificial\s+intelligence|machine\s+learning|deep\s+learning|data\s+scien(?:ce|tist)|tensorflow|pytorch|scikit[- ]?learn|llm|langchain|developer|software\s+engineer|programmer|разработчик|программист|react|angular|vue(?:\.js)?|next(?:\.js)?|node(?:\.js)?|django|laravel|spring\s+boot|asp\.net)\b/iu.test(skills);
186
+ if (sysadminBase && !specializedTools) return Object.freeze(['System Administrator']);
187
+
188
+ const strongDeveloperSkill = /\b(?:react|vue\.?\s*js|angular|node\.?\s*js|next\.?\s*js|django|laravel|fastapi|flask|spring|asp\.?net|ruby\s+on\s+rails)\b/iu.test(skills);
189
+ const softwareSignals = (skills.match(/\b(?:python|java(?:script)?|typescript|php|react|vue\.?\s*js|angular|node\.?\s*js|next\.?\s*js|django|laravel|fastapi|flask|spring|asp\.?net|sql|html|css|c\+\+|c#|golang)\b/giu) || []).length;
190
+ return strongDeveloperSkill || softwareSignals >= 2
191
+ ? Object.freeze(['Software Developer'])
192
+ : Object.freeze([]);
193
+ }
194
+
195
+ export function detectManagementRole(title, value = '') {
196
+ const heading = String(title || '');
197
+ const text = String(value || '');
198
+ if (/\b(?:head|director|chief|manager|supervisor|team\s*lead|tech\s*lead)\b|руководител|начальник|директор|тимлид|заведующ/i.test(heading)) return true;
199
+ if (/manage(?:s|ment|ing)?\s+(?:a\s+)?(?:team|people|staff)|people management|direct reports|руковод(?:ить|ство)\s+(?:команд|сотруд)|управлени[ея]\s+(?:команд|персонал)/i.test(text)) return true;
200
+ return null;
201
+ }
202
+
203
+ export function detectHiringScopeSignals(value, options = {}) {
204
+ const mode = options.mode === 'candidate' ? 'candidate' : 'vacancy';
205
+ const text = String(value || '');
206
+ return Object.freeze(SCOPE_SIGNALS.filter((signal) => signal[mode].test(text)).map((signal) => signal.code));
207
+ }
208
+
209
+ export function detectDegreeRequirement(value) {
210
+ const text = String(value || '');
211
+ const level = detectDegreeLevel(text) || undefined;
212
+ const fields = detectDegreeFields(text);
213
+ let field;
214
+ if (fields.includes('computer_science')) field = 'computer_science';
215
+ else if (fields.includes('engineering') && /\bengineering\b[^.;\n]{0,60}\bdegree\b|\bdegree\b[^.;\n]{0,60}\bengineering\b/i.test(text)) field = 'engineering';
216
+ const equivalentExperience = /equivalent\s+(?:professional\s+|work\s+)?experience|or\s+equivalent\s+experience/i.test(text);
217
+ return Object.freeze({ level, field, equivalentExperience });
218
+ }
@@ -0,0 +1,21 @@
1
+ export interface SkillMeta {
2
+ category: string;
3
+ subcategory: string;
4
+ }
5
+ export interface SkillDefinition extends SkillMeta {
6
+ name: string;
7
+ aliases: string[];
8
+ }
9
+ export interface SkillDetail extends SkillMeta {
10
+ name: string;
11
+ }
12
+ export const SKILL_CATALOG: SkillDefinition[];
13
+ export const SKILL_KEYWORDS: Readonly<Record<string, readonly string[]>>;
14
+ export const SKILL_META: Readonly<Record<string, SkillMeta>>;
15
+ export function normalizeSkillText(value: string): string;
16
+ export function escapeRegex(value: string): string;
17
+ export function buildSkillRegex(alias: string): RegExp;
18
+ export function canonicalSkillName(value: string): string | undefined;
19
+ export function extractSkillDetails(text: string): SkillDetail[];
20
+ export function extractSkillNames(text: string): string[];
21
+ export function getSkillMeta(name: string): SkillMeta | undefined;