@whiteslove/parsing-lexicon 0.7.5 → 0.7.7
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/package.json +1 -1
- package/src/hiring-ats.d.ts +1 -1
- package/src/hiring-ats.js +4 -1
- package/src/hiring-requirements.js +1 -1
- package/src/housing-address.js +22 -3
- package/src/housing-listing-enrichment.d.ts +1 -1
- package/src/housing-listing-enrichment.js +5 -2
- package/src/housing-poi-relations.js +6 -1
package/package.json
CHANGED
package/src/hiring-ats.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HiringSeniority, DegreeLevel, DegreeField } from './hiring-requirements.js';
|
|
2
2
|
export type HiringAtsProfile = Readonly<{ raw: string; skills: Set<string>; skillEvidence: Map<string, number>; terms?: Set<string>; experienceYears?: number; seniority?: HiringSeniority; degreeLevel?: DegreeLevel; degreeFields: Set<DegreeField>; requiresUsSponsorship?: boolean }>;
|
|
3
|
-
export type HiringAtsJob = Readonly<{ title: string; description?: string; tags?: string[]; skills?: string[]; niceToHave?: string[]; experienceMinYears?: number; seniority?: string | null; education?: string; country?: string; location?: string }>;
|
|
3
|
+
export type HiringAtsJob = Readonly<{ title: string; description?: string; tags?: string[]; skills?: string[]; niceToHave?: string[]; experienceMinYears?: number; seniority?: string | null; education?: string; country?: string; location?: string; sponsorshipEvidence?: string[] }>;
|
|
4
4
|
export function buildHiringAtsProfile(cvText: unknown, options?: { fuzzySkills?: boolean; referenceDate?: Date }): HiringAtsProfile;
|
|
5
5
|
export function scoreHiringAts(profileOrCv: HiringAtsProfile | string, job: HiringAtsJob, options?: { fuzzySkills?: boolean; referenceDate?: Date }): Readonly<{ score: number; fitScore: number; eligible: boolean; blockers: readonly { code: string; label: string; critical: boolean }[]; breakdown: Readonly<{ skills: number; experience: number; seniority: number; scope: number; education: number; relevance: number }>; matched: readonly string[]; missing: readonly string[] }>;
|
|
6
6
|
export function hiringAtsScoreColor(score: number): string;
|
package/src/hiring-ats.js
CHANGED
|
@@ -137,7 +137,10 @@ export function scoreHiringAts(profileOrCv, job, options = {}) {
|
|
|
137
137
|
const relevance = extractTerms(keywordSource).size ? Math.round(coverage(profile.terms || extractTerms(profile.raw), extractTerms(keywordSource)) * 100) : 60;
|
|
138
138
|
const breakdown = Object.freeze({ skills, experience: experience.score, seniority: seniority.score, scope: scope.score, education: education.score, relevance });
|
|
139
139
|
let fitScore = Math.round(skills * .30 + experience.score * .20 + seniority.score * .20 + scope.score * .15 + education.score * .10 + relevance * .05);
|
|
140
|
-
|
|
140
|
+
// Source-specific ATS ingestion can supply the wording that established a
|
|
141
|
+
// sponsorship policy separately from the rendered description. It remains
|
|
142
|
+
// input evidence, not consumer-side parsing logic.
|
|
143
|
+
const usText = `${job?.location || ''} ${title} ${description} ${tagText} ${(job?.sponsorshipEvidence || []).join(' ')}`;
|
|
141
144
|
const visaBlocked = (job?.country || '').toUpperCase() === 'US' || detectCountryCodeFromText(usText) === 'US'
|
|
142
145
|
? profile.requiresUsSponsorship === true && isNoSponsorshipRequirement(usText) : false;
|
|
143
146
|
const blockers = visaBlocked ? [Object.freeze({ code: 'visa_sponsorship', label: 'Visa sponsorship unavailable', critical: true })] : [];
|
|
@@ -72,7 +72,7 @@ export function requiresUsSponsorship(value) {
|
|
|
72
72
|
|
|
73
73
|
export function isNoSponsorshipRequirement(value) {
|
|
74
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);
|
|
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|\b(?:does|do)\s+not\s+(?:offer|provide|support)\s+(?:current\s+or\s+future\s+)?(?:visa\s+|employment\s+)?sponsorship\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
76
|
}
|
|
77
77
|
|
|
78
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;
|
package/src/housing-address.js
CHANGED
|
@@ -23,6 +23,7 @@ const ADDRESS_FIELD_STOP_RE = /\s+(?:цена|ціна|нарх(?:и)?|narx|pric
|
|
|
23
23
|
const PROPERTY_AREA_LINE_RE = /(?:^|[^\p{L}\p{N}_])(?:(?:общая|жилая|полезная|кухонная)\s+площадь|площадь\s+(?:квартиры|дома|комнаты))(?=$|[^\p{L}\p{N}_])/iu;
|
|
24
24
|
const NON_ADDRESS_BARE_RE = /^(?:(?:(?:перш(?:ий|ому)|перв(?:ый|ом)|друг(?:ий|ому)|втор(?:ой|ом)|трет(?:ій|ьем|ий)|\d{1,3}(?:-?й)?)\s+(?:поверх|этаж|floor|qavat|қабат))|(?:поверх|этаж|floor|qavat|қабат)(?:\s|$)|(?:район|р-н|рн|мікрорайон|микрорайон|мкр\.?|жк|ж\.к\.|жилой\s+комплекс|житловий\s+комплекс|residential\s+complex)(?:\s|$)|(?:недалеко|поруч|рядом|біля|около|возле)(?=$|[^\p{L}\p{N}_])|(?:зупинка|остановка|станція|станция)(?:\s|$))/iu;
|
|
25
25
|
const DELIMITED_STREET_REJECT_RE = /(?:^|\s)(?:город|місто|city|район|р-н|рн|мікрорайон|микрорайон|мкр|жк|метро|поверх|этаж|floor|qavat|кімнат\p{L}*|комнат\p{L}*|квартира|квартири|квартиры|оренда|аренда|продаж\p{L}*|цена|ціна|площад\p{L}*|площа|зупинка|остановка|ориентир\p{L}*|ор[-–—]?р\.?)(?:\s|$)/iu;
|
|
26
|
+
const LOCATION_RELATION_RE = /(?:yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida|near(?:by)?|close\s+to|next\s+to|рядом|возле|около|недалеко|поруч|біля|lângă|aproape)/iu;
|
|
26
27
|
const UNIT_COMPONENT_PATTERNS = Object.freeze([
|
|
27
28
|
String.raw`(?:^|[\s,;])(?:кв\.?|кв-ра)(?!\p{L})\s*(?:№|#)?\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
28
29
|
String.raw`(?:^|[\s,;])квартира\s*(?:№|#)\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
@@ -188,9 +189,12 @@ function result(address, street = null, houseNumber = null, building = null, con
|
|
|
188
189
|
const normalizedHouseNumber = normalizeNumber(houseNumber);
|
|
189
190
|
const compactBuilding = normalizedHouseNumber?.match(/^(\d{1,5})(?:к|k)(\d{1,4})$/iu);
|
|
190
191
|
const normalizedBuilding = normalizeNumber(building) || compactBuilding?.[2] || null;
|
|
191
|
-
|
|
192
|
+
// The public address is always a verified street component, optionally
|
|
193
|
+
// followed by its house/building. Never preserve a source label's whole
|
|
194
|
+
// location sentence as an address just because it was parsed on that path.
|
|
195
|
+
const canonicalAddress = normalizedStreet
|
|
192
196
|
? composeHousingAddress({ street: normalizedStreet, houseNumber: compactBuilding ? compactBuilding[1] : normalizedHouseNumber, building: normalizedBuilding })
|
|
193
|
-
:
|
|
197
|
+
: null;
|
|
194
198
|
return Object.freeze({
|
|
195
199
|
address: canonicalAddress,
|
|
196
200
|
street: normalizedStreet,
|
|
@@ -536,7 +540,22 @@ function labelledAddress(text, rawValue) {
|
|
|
536
540
|
|
|
537
541
|
const line = clean(rawLine).slice(0, 140);
|
|
538
542
|
if (!line) return null;
|
|
539
|
-
|
|
543
|
+
const structured = parseHousingAddress(line, { allowDelimitedBare: true });
|
|
544
|
+
if (structured.street || structured.houseNumber) return structured;
|
|
545
|
+
|
|
546
|
+
// A label alone is not evidence that arbitrary prose is a street. Accept
|
|
547
|
+
// an unmarked form only when it is a short street-plus-house expression;
|
|
548
|
+
// landmark lists such as "Yakkasaroy, Magic City yonida" remain geo data.
|
|
549
|
+
const tail = splitAddressTail(line);
|
|
550
|
+
const candidateStreet = compactStreet(tail?.street);
|
|
551
|
+
const safeBare = Boolean(
|
|
552
|
+
tail?.houseNumber
|
|
553
|
+
&& candidateStreet
|
|
554
|
+
&& !LOCATION_RELATION_RE.test(candidateStreet)
|
|
555
|
+
&& candidateStreet.split(/\s+/u).length <= 5
|
|
556
|
+
&& candidateStreet.split(/\s+/u).every((word) => /^[\p{L}'’.-]{2,48}$/u.test(word)),
|
|
557
|
+
);
|
|
558
|
+
return safeBare ? parseHousingAddress(line, { allowBare: true }) : structured;
|
|
540
559
|
}
|
|
541
560
|
|
|
542
561
|
function plausibleDelimitedStreet(value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HousingAudience = 'women' | 'men' | 'family';
|
|
1
|
+
export type HousingAudience = 'women' | 'men' | 'family' | 'students';
|
|
2
2
|
export interface HousingPerPersonPrice { amount: number; currency: string | null; approximate: boolean; scope: 'person' }
|
|
3
3
|
export interface HousingCommissionAmount { amount: number; currency: string | null; approximate: boolean }
|
|
4
4
|
export interface HousingListingEnrichment {
|
|
@@ -30,12 +30,12 @@ const APPLIANCE_PATTERNS = Object.freeze([
|
|
|
30
30
|
|
|
31
31
|
const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
|
|
32
32
|
const LANDLORD_PRESENT_RE = /(?:xozaykali|hojaykali|xo['’]?jaykali|с\s+хозяйк(?:ой|ой\s+в\s+квартире)|хозяйк\p{L}*\s+(?:жив[её]т|прожива\p{L}*)|with\s+(?:the\s+)?(?:landlord|owner)\s+(?:present|living\s+in)|cu\s+proprietar(?:ul)?\s+în\s+cas(?:ă|a)|үй\s*иесі\s+(?:тұрады|бірге\s+тұрады))/iu;
|
|
33
|
-
const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s+студент|students?\s+(?:only|welcome)|for\s+students|pentru\s+studen[țt]i|studen[țt]i(?:lor)?|студенттерге|студенттер\s
|
|
33
|
+
const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s+студент|students?\s+(?:only|welcome)|for\s+students|pentru\s+studen[țt]i|studen[țt]i(?:lor)?|студенттерге|студенттер\s+үшін|(?:oila|oyla)(?:ga|lar|li)?\s+yoki\s+\d{1,2}\s+ta\s+bola(?:lar)?(?:ga)?\s+(?:ijara(?:ga)?\s+)?(?:beril|topshiril))/iu;
|
|
34
34
|
const NO_BROKER_RE = /(?:bez\s+makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси)|no\s+(?:broker|agent|commission|agency\s+fee)|f[ăa]r[ăa]\s+(?:comision|agen[țt]ie|intermediari)|делдалсыз|комиссиясыз)/iu;
|
|
35
35
|
const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
|
|
36
36
|
const MEN_RE = /(?:o['’ʻʼ‘`]?g['’ʻʼ‘`]?il\s+bola(?:lar)?(?:ga)?|ogil\s+bola(?:lar)?(?:ga)?|sherik\s+bola|эркак(?:лар)?|erkak(?:lar)?(?:ga)?|только\s+(?:мужчин|парн)|\bmen\s+only\b|b[ăa]rba[țt]i(?:lor)?|b[ăa]ie[țt]i(?:lor)?|жігіттерге|жігіттер(?:ге)?|хлопц(?:ям|і|ів)?|чоловік(?:ам|и)?)/iu;
|
|
37
37
|
const WOMEN_RE = /(?:qiz(?:lar)?(?:ga)?|ayol(?:lar)?(?:ga|ni)?|киз(?:лар)?(?:га)?|аёл(?:лар)?(?:га|ни)?|девушк\p{L}*|женщин\p{L}*|girls?\s+only|women\s+only|fete(?:lor)?|femei(?:lor)?|қыздарға|қыздар(?:ға)?|дівчат(?:ам|а|ок)?|жінк(?:ам|и)?)/iu;
|
|
38
|
-
const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga|famil(?:ie|ia)|cuplu(?:ri)?|отбасына|отбасылы|жанұяға|сім['’ʼ]?[яїі](?:ям|ям[иі])?|сімейн\p{L}*)/iu;
|
|
38
|
+
const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oyla(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga|famil(?:ie|ia)|cuplu(?:ri)?|отбасына|отбасылы|жанұяға|сім['’ʼ]?[яїі](?:ям|ям[иі])?|сімейн\p{L}*)/iu;
|
|
39
39
|
const ROOM_SHARE_RE = /(?:sherik(?:ka|lik|likga)?|шерик(?:ка|лик)?|roommate|flatmate|подселени|койко[-\s]?мест|место\s+в\s+(?:комнат|квартир)|birga\s+yashash(?:ga)?|kvartira(?:ga|da)?[^\r\n.!?]{0,36}(?:\d+|bitta|1)\s*(?:ta\s*)?(?:qiz|ayol)[^\r\n.!?]{0,20}(?:ijarachi\s*)?(?:kerak|kere)|coleg\s+de\s+(?:apartament|camer[ăa])|bed\s+space|бөлмелес(?:\s+керек)?|көрші\s+керек)/iu;
|
|
40
40
|
const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
|
|
41
41
|
const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
|
|
@@ -138,7 +138,10 @@ export function parseHousingAudience(value) {
|
|
|
138
138
|
const family = FAMILY_RE.test(text);
|
|
139
139
|
const women = WOMEN_RE.test(text);
|
|
140
140
|
const men = MEN_RE.test(text);
|
|
141
|
+
const students = STUDENT_RE.test(text);
|
|
142
|
+
if (family && students) return deepFreeze({ primary: 'family', alternatives: ['family', 'students'] });
|
|
141
143
|
if (family && women) return deepFreeze({ primary: 'family', alternatives: ['family', 'women'] });
|
|
144
|
+
if (students) return deepFreeze({ primary: 'students', alternatives: ['students'] });
|
|
142
145
|
if (family) return deepFreeze({ primary: 'family', alternatives: ['family'] });
|
|
143
146
|
if (women) return deepFreeze({ primary: 'women', alternatives: ['women'] });
|
|
144
147
|
if (men) return deepFreeze({ primary: 'men', alternatives: ['men'] });
|
|
@@ -18,11 +18,16 @@ const TYPE_MARKERS = Object.freeze([
|
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
20
|
const RELATION_RE = /(?<relation>рядом\s+(?:с|со)|возле|около|недалеко\s+от|напротив|за|перед|near(?:by)?|close\s+to|next\s+to|opposite|behind|in\s+front\s+of|yaqin(?:ida)?|yonida|ro['’ʻʼ`]?parasida|орналасқан\s+жерде|поруч|біля|поблизу|lângă|aproape\s+de)\s+(?<target>[^,;.!?\r\n]{2,96})/giu;
|
|
21
|
+
// Uzbek usually places the relation after the landmark: "Magic City yonida"
|
|
22
|
+
// rather than "yonida Magic City". Keep its target span separate so it never
|
|
23
|
+
// leaks into an address/street field.
|
|
24
|
+
const POSTFIX_RELATION_RE = /(?<target>[^,;.!?\r\n]{2,96}?)\s+(?<relation>yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida)(?=$|[,;.!?\r\n])/giu;
|
|
21
25
|
const DISTANCE_RE = /(?<amount>\d{1,3}(?:[.,]\d+)?)\s*(?<unit>km|км|min(?:ute)?s?|мин(?:ут(?:ы|а|ах)?)?|дақиқа|daqiqa|метр(?:а|ов)?|m)\s*(?<mode>пешком|пішки|walking?|yayov|piyoda|на\s+машине|by\s+car)?\s*(?:до|от|from|to|до\s+станции)\s+(?<target>[^,;.!?\r\n]{2,96})/giu;
|
|
22
26
|
|
|
23
27
|
function cleanTarget(value) {
|
|
24
28
|
return String(value || '')
|
|
25
29
|
.replace(/\b(?:на\s+машине|пешком|пішки|walking?|piyoda|yayov)\b/giu, ' ')
|
|
30
|
+
.replace(/(?<!\p{L})(\p{L}{3,})(?:ga|qa|ka)(?!\p{L})/giu, '$1')
|
|
26
31
|
.replace(/\s+/g, ' ').trim();
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -91,7 +96,7 @@ export function extractHousingPoiRelations(value, { country = '', city = '', res
|
|
|
91
96
|
if (!text || !normalizedCountry || typeof resolveGeoCandidates !== 'function') return deepFreeze([]);
|
|
92
97
|
const context = { country: normalizedCountry, city, resolveGeoCandidates };
|
|
93
98
|
const relations = [];
|
|
94
|
-
for (const pattern of [RELATION_RE, DISTANCE_RE]) {
|
|
99
|
+
for (const pattern of [RELATION_RE, POSTFIX_RELATION_RE, DISTANCE_RE]) {
|
|
95
100
|
for (const match of text.matchAll(pattern)) {
|
|
96
101
|
const groups = match.groups || {};
|
|
97
102
|
const target = groups.target || '';
|