@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- const usText = `${job?.location || ''} ${title} ${description}`;
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;
@@ -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
- const canonicalAddress = normalizedStreet && normalizedHouseNumber
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
- : clean(address) || null;
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
- return parseHousingAddress(line, { allowBare: true });
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+үшін)/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+үшін|(?: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 || '';