@whiteslove/parsing-lexicon 0.2.7 → 0.2.10

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.2.7",
3
+ "version": "0.2.10",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for Whiteslove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contact.js CHANGED
@@ -166,7 +166,9 @@ export function parsePrimaryContact(value) {
166
166
  const digits = intl[0].replace(/\D/g, '');
167
167
  if (digits.length >= 10 && digits.length <= 15) return `+${digits}`;
168
168
  }
169
- const keyword = text.match(/(?:tel|тел|phone|моб|whats?app|viber|telegram|звонит|звоніть|aloqa|byla|contact)[^\d+]{0,8}(\+?\d[\d\s().-]{6,}\d)/iu);
169
+ // Bounded like the `trailing` keyword below: "тел"/"phone" must be a whole
170
+ // word, not a suffix of an unrelated word ("хостел", "котел").
171
+ const keyword = text.match(/(?<![\p{L}\p{N}_])(?:tel|тел|phone|моб|whats?app|viber|telegram|звонит|звоніть|aloqa|byla|contact)(?![\p{L}\p{N}_])[^\d+]{0,8}(\+?\d[\d\s().-]{6,}\d)/iu);
170
172
  if (keyword) {
171
173
  const digits = keyword[1].replace(/\D/g, '');
172
174
  if (digits.length >= 9 && digits.length <= 15) return keyword[1].trim();
@@ -0,0 +1,22 @@
1
+ import { parseHiringContext as parseBaseHiringContext } from './hiring-context.js';
2
+
3
+ const EXPLICIT_POSITIVE_VISA_SPONSORSHIP_RE = /\bwe\s+do\s+sponsor\s+visas?\b/i;
4
+
5
+ /**
6
+ * Compatibility refinements for real-world hiring copy that is intentionally
7
+ * more specific than the generic alias catalog. Keep this wrapper tiny so the
8
+ * base multilingual context parser remains the single source of truth.
9
+ */
10
+ export function parseHiringContext(value, options = {}) {
11
+ const text = String(value || '');
12
+ const parsed = parseBaseHiringContext(text, options);
13
+ if (!EXPLICIT_POSITIVE_VISA_SPONSORSHIP_RE.test(text)
14
+ || parsed.workAuthorization.includes('sponsorshipOffered')) {
15
+ return parsed;
16
+ }
17
+
18
+ return Object.freeze({
19
+ ...parsed,
20
+ workAuthorization: Object.freeze([...parsed.workAuthorization, 'sponsorshipOffered']),
21
+ });
22
+ }
@@ -19,7 +19,10 @@ const STATUS_ONLY_RE = /^(?:talaba|student|студент(?:ка)?|студен
19
19
  const FLEXIBLE_ROLE_RE = /^(?:нет|без)\s+разницы(?:\s+.*)?$|^не\s*важно(?:\s+.*)?$|^farqi\s+yo['’ʻʼ‘`]?q$|^любая\s+(?:работа|занятость)(?:\s+.*)?$/iu;
20
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
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;
22
+ // The open `\p{L}*` suffixes used to match "удаленность"/"віддаленості" (an
23
+ // object's distance from something) as a remote-work preference. Case
24
+ // endings are now enumerated explicitly and both stems are token-bounded.
25
+ const REMOTE_POSITIVE_RE = /(?:\bremote\b|\bremotely\b|(?<!\p{L})удал[её]н(?:но|ка|н(?:ый|ая|ую|ых|ым|ой|ое|ые))?(?!\p{L})|(?<!\p{L})віддален(?:о|а|ий)?(?!\p{L})|дистанц(?:ионно|ійно)|masofaviy|(?<!\p{L})onlayn(?!\p{L})|online\s+(?:work|job)|онлайн\s+работ)/iu;
23
26
 
24
27
  const FEATURE_RULES = Object.freeze([
25
28
  ['student', /\bstudent\b|студент|студентк|talaba/iu],
@@ -197,7 +197,12 @@ export function detectVisaSponsorshipWording(value) {
197
197
  return null;
198
198
  }
199
199
 
200
- export const TEMPORARY_WORK_AUTH_RE = /\b(?:opt|cpt|stem\s+opt)\b/iu;
200
+ // Bare "opt" needs case-sensitive "OPT" (the Optional Practical Training
201
+ // abbreviation is written that way in real postings) — a case-insensitive
202
+ // `\bopt\b` also matched the common English verb ("opt-in", "opt out").
203
+ // "cpt" and "stem opt" don't collide with ordinary words, so they stay
204
+ // case-insensitive via explicit character classes instead of the `i` flag.
205
+ export const TEMPORARY_WORK_AUTH_RE = /\b(?:[Cc][Pp][Tt]|[Ss][Tt][Ee][Mm]\s+[Oo][Pp][Tt])\b|\bOPT\b/u;
201
206
 
202
207
  export function detectRecruitmentAgency(value) {
203
208
  return /recruit(?:ment|ing) agency|staffing agency|talent agency|кадров(?:ое|е) агентство|рекрут(?:ингов|инг)\p{L}* агентство|агентство по подбору/iu.test(String(value || ''));
@@ -1,4 +1,4 @@
1
- export type HousingTextLanguage = 'ru' | 'uk' | 'en' | 'uz';
1
+ export type HousingTextLanguage = 'ru' | 'uk' | 'en' | 'uz' | 'ro' | 'kk';
2
2
 
3
3
  export function housingTextIsInLanguage(value: unknown, language: string): boolean;
4
4
  export function detectHousingTextLanguage(value: unknown): HousingTextLanguage | null;
@@ -18,7 +18,7 @@ const HOUSING_LANGUAGE_SIGNALS = Object.freeze({
18
18
  minimum: 2,
19
19
  },
20
20
  uk: {
21
- pattern: /(?:квартир\p{L}*|кімнат\p{L}*|поверх\p{L}*|будинк\p{L}*|цін\p{L}*|здається|продається|оренд\p{L}*|поруч|метро|сімейн\p{L}*|комунальн\p{L}*|ремонт\p{L}*|меблі\p{L}*|балкон\p{L}*|район\p{L}*)/giu,
21
+ pattern: /(?:квартир\p{L}*|кімнат\p{L}*|поверх\p{L}*|будинк\p{L}*|цін\p{L}*|здається|продається|оренд\p{L}*|поруч|метро|сімейн\p{L}*|комунальн\p{L}*|ремонт\p{L}*|мебл\p{L}*|балкон\p{L}*|район\p{L}*)/giu,
22
22
  minimum: 2,
23
23
  },
24
24
  en: {
@@ -29,6 +29,14 @@ const HOUSING_LANGUAGE_SIGNALS = Object.freeze({
29
29
  pattern: /(?:kvartira\p{L}*|xona\p{L}*|qavat\p{L}*|uy\p{L}*|narx\p{L}*|ijara\p{L}*|beriladi|sotiladi|yaqin|metro|mebel\p{L}*|balkon\p{L}*|tuman\p{L}*)/giu,
30
30
  minimum: 2,
31
31
  },
32
+ ro: {
33
+ pattern: /(?:apartament\p{L}*|cameră\p{L}*|camera\p{L}*|etaj\p{L}*|cas[aă]\p{L}*|case\p{L}*|pre[țt]\p{L}*|închiri\p{L}*|inchiri\p{L}*|vânz\p{L}*|vanz\p{L}*|metrou\p{L}*|famil\p{L}*|utilit\p{L}*|mobilat\p{L}*|balcon\p{L}*|cartier\p{L}*)/giu,
34
+ minimum: 2,
35
+ },
36
+ kk: {
37
+ pattern: /(?:пәтер\p{L}*|бөлме\p{L}*|қабат\p{L}*|үй\p{L}*|баға\p{L}*|жалд\p{L}*|жалғ\p{L}*|сат\p{L}*|жақын\p{L}*|метро\p{L}*|отбасы\p{L}*|коммунал\p{L}*|жиһаз\p{L}*|балкон\p{L}*|аудан\p{L}*)/giu,
38
+ minimum: 2,
39
+ },
32
40
  });
33
41
 
34
42
  function signalCount(text, language) {
@@ -10,7 +10,15 @@ import {
10
10
  import { maskPhoneLikeSpans } from './contact.js';
11
11
 
12
12
  const PRICE_KEYWORD = '(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|аренд(?:а|ная\\s+плата)?|rent)';
13
- const PRICE_CURRENCY = `(?:${moneyCurrencyPattern()})`;
13
+ // moneyCurrencyPattern() includes short codes (cad, ron, aed...) with no
14
+ // boundary of its own, so "100 cadastru" would otherwise read "cad" off an
15
+ // unrelated word as the Canadian dollar. Only the side facing away from the
16
+ // paired number gets a boundary: the side facing the number is legitimately
17
+ // adjacent to a digit with no separator ("350$", "$100"), so guarding it
18
+ // too would reject those ordinary forms.
19
+ const CURRENCY_ALT = `(?:${moneyCurrencyPattern()})`;
20
+ const PRICE_CURRENCY_AFTER_NUMBER = `(?:${CURRENCY_ALT}(?![\\p{L}\\p{N}_]))`;
21
+ const PRICE_CURRENCY_BEFORE_NUMBER = `(?:(?<![\\p{L}\\p{N}_])${CURRENCY_ALT})`;
14
22
 
15
23
  function escapeRegex(value) {
16
24
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -51,8 +59,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
51
59
 
52
60
  if (price == null) {
53
61
  let tagged = null;
54
- const reNumSym = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*${PRICE_CURRENCY}`, 'ig');
55
- const reSymNum = new RegExp(`${PRICE_CURRENCY}\\s*(${MONEY_NUMBER_PATTERN})`, 'ig');
62
+ // 'u' is required for the \p{L}/\p{N} boundary escapes to work as
63
+ // Unicode property classes without it they silently match nothing,
64
+ // which had made the boundary guard a no-op.
65
+ const reNumSym = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*${PRICE_CURRENCY_AFTER_NUMBER}`, 'igu');
66
+ const reSymNum = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
56
67
  for (const regex of [reNumSym, reSymNum]) {
57
68
  let match;
58
69
  while ((match = regex.exec(text)) !== null) {
@@ -7,6 +7,47 @@ import { isRoomOnlyHousing } from './housing-source-aliases.js';
7
7
  // demand for one woman, which it is not.
8
8
  const CLAUSE = String.raw`[^\r\n.!?]`;
9
9
 
10
+ // "не" can attach to either side of the demand — "не нужна одна девушка" and
11
+ // "одна девушка не нужна" both negate it — so both the count word and the
12
+ // verb carry their own guard. Scoped tightly to right before each token
13
+ // rather than a whole-text negation scan, so an unrelated "не" elsewhere in
14
+ // the clause (e.g. "девушку, не курящую" — a non-smoking woman) does not
15
+ // suppress a real demand.
16
+ const NOT_NEGATED_RU = String.raw`(?<!не\s{0,3})`;
17
+
18
+ // `\b` is ASCII-only in JS even under the `u` flag — it never fires around
19
+ // Cyrillic text, so a lookahead built on `\b` after "эмас" silently never
20
+ // matches and the guard becomes a no-op. Use the same explicit
21
+ // letter/digit/underscore boundary the rest of the lexicon relies on.
22
+ const WORD_END = String.raw`(?=$|[^\p{L}\p{N}_])`;
23
+ const NOT_NEGATED_UZ = String.raw`(?!\s*emas${WORD_END})`;
24
+ const NOT_NEGATED_UZ_CYRL = String.raw`(?!\s*эмас${WORD_END})`;
25
+
26
+ const NOT_NEGATED_RO = String.raw`(?<!nu\s{0,3})`;
27
+ const NOT_NEGATED_KK = String.raw`(?!\s*емес${WORD_END})`;
28
+
29
+ // Romanian places the "single" adjective on either side of the noun — "o
30
+ // singură fată" and "o fată singură" are both natural — so the count phrase
31
+ // covers both orders rather than picking one.
32
+ const RO_FEMALE_NOUN = String.raw`(?:fat[aă]\p{L}*|fete\p{L}*|femei\p{L}*)`;
33
+ const RO_COUNT_NOUN = String.raw`(?:o\s+singur[aă]\s+${RO_FEMALE_NOUN}|o\s+${RO_FEMALE_NOUN}\s+singur[aă]|1\s+${RO_FEMALE_NOUN})`;
34
+
35
+ // A point guard right before the verb only blocks *that* instance of the verb
36
+ // from matching — it does not stop the engine from skipping past a negated
37
+ // "не нужна" to a second, unnegated verb word later in the same gap (e.g.
38
+ // "не нужна, ищем" would otherwise still complete via "ищем"). Building the
39
+ // gap out of "not the start of a standalone negation word" repeated
40
+ // character-by-character keeps any negation out of the whole span, not just
41
+ // its own position. `emas`/`эмас`/`емес` trail their verb rather than lead
42
+ // it ("kerak emas"), so those languages' gaps guard the same way as their
43
+ // point guard — excluding the negation word wherever it falls in the gap.
44
+ const gapExcluding = (word, max) => `(?:(?!${word}${WORD_END})${CLAUSE}){0,${max}}`;
45
+ const GAP_NO_NEGATION_RU = (max) => gapExcluding('не', max);
46
+ const GAP_NO_NEGATION_RO = (max) => gapExcluding('nu', max);
47
+ const GAP_NO_NEGATION_UZ = (max) => gapExcluding('emas', max);
48
+ const GAP_NO_NEGATION_UZ_CYRL = (max) => gapExcluding('эмас', max);
49
+ const GAP_NO_NEGATION_KK = (max) => gapExcluding('емес', max);
50
+
10
51
  /**
11
52
  * Wording that seeks exactly one female tenant, as opposed to the generic
12
53
  * "women only" audience wording that ordinary women-only listings use.
@@ -16,30 +57,63 @@ const CLAUSE = String.raw`[^\r\n.!?]`;
16
57
  const SINGLE_FEMALE_TENANT_PATTERNS = Object.freeze([
17
58
  // ru: "только одна девушка", "нужна 1 девушка", "ищу одну женщину", "подселю одну девушку"
18
59
  new RegExp(
19
- String.raw`(?:только|лише|нужн\p{L}*|потрібн\p{L}*|ищ[еуy]\p{L}*|шука\p{L}*|подсел\p{L}*|підсел\p{L}*)`
20
- + CLAUSE + `{0,24}`
21
- + String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у|ту)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`,
60
+ NOT_NEGATED_RU
61
+ + String.raw`(?:только|лише|нужн\p{L}*|потрібн\p{L}*|ищ[еуy]\p{L}*|шука\p{L}*|подсел\p{L}*|підсел\p{L}*)`
62
+ + GAP_NO_NEGATION_RU(24)
63
+ + NOT_NEGATED_RU
64
+ + String.raw`[^\p{L}\p{N}_](?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`,
22
65
  'iu',
23
66
  ),
24
67
  // uk/ru reversed order: "одна девушка нужна"
25
68
  new RegExp(
26
- String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`
27
- + CLAUSE + `{0,18}`
69
+ NOT_NEGATED_RU
70
+ + String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`
71
+ + GAP_NO_NEGATION_RU(18)
72
+ + NOT_NEGATED_RU
28
73
  + String.raw`(?:нужн\p{L}*|потрібн\p{L}*|треба|ищ[еуy]\p{L}*|шука\p{L}*)`,
29
74
  'iu',
30
75
  ),
76
+ // ro: "doar o singură fată", "am nevoie de 1 fată", "caut o fată singură"
77
+ new RegExp(
78
+ NOT_NEGATED_RO
79
+ + String.raw`(?:doar|caut\p{L}*|căut\p{L}*|trebuie|nevoie\p{L}*)`
80
+ + GAP_NO_NEGATION_RO(24)
81
+ + NOT_NEGATED_RO
82
+ + `[^\\p{L}\\p{N}_]${RO_COUNT_NOUN}`,
83
+ 'iu',
84
+ ),
85
+ // ro reversed order: "o singură fată e nevoie"
86
+ new RegExp(
87
+ NOT_NEGATED_RO
88
+ + `(?:^|[^\\p{L}\\p{N}_])${RO_COUNT_NOUN}`
89
+ + GAP_NO_NEGATION_RO(18)
90
+ + NOT_NEGATED_RO
91
+ + String.raw`(?:doar|trebuie|nevoie\p{L}*|caut[aă]\p{L}*)`,
92
+ 'iu',
93
+ ),
31
94
  // uzLatn: "faqat 1 ta qiz kerak", "bitta ayol ijarachi kerak"
32
95
  new RegExp(
33
96
  String.raw`(?:faqat\s+)?(?:^|[^\p{L}\p{N}_])(?:1|bitta)\s*(?:ta\s*)?(?:qiz|ayol)`
34
- + CLAUSE + `{0,18}`
35
- + String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)`,
97
+ + GAP_NO_NEGATION_UZ(18)
98
+ + String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)`
99
+ + NOT_NEGATED_UZ,
36
100
  'iu',
37
101
  ),
38
102
  // uzCyrl: "фақат 1 та қиз керак"
39
103
  new RegExp(
40
104
  String.raw`(?:фақат\s+)?(?:^|[^\p{L}\p{N}_])(?:1|битта)\s*(?:та\s*)?(?:қиз|аёл)`
41
- + CLAUSE + `{0,18}`
42
- + String.raw`(?:ижарачи\s*)?(?:керак|керакли)`,
105
+ + GAP_NO_NEGATION_UZ_CYRL(18)
106
+ + String.raw`(?:ижарачи\s*)?(?:керак|керакли)`
107
+ + NOT_NEGATED_UZ_CYRL,
108
+ 'iu',
109
+ ),
110
+ // kk: "тек бір қыз керек", "бір қызға орын керек" — Kazakh is verb-final,
111
+ // so unlike the Slavic/Romance pairs above only this one order is natural.
112
+ new RegExp(
113
+ String.raw`(?:тек\s+)?(?:^|[^\p{L}\p{N}_])(?:бір|1)\s+(?:қыз|әйел)\p{L}*`
114
+ + GAP_NO_NEGATION_KK(18)
115
+ + String.raw`(?:керек|қажет|ізде)\p{L}*`
116
+ + NOT_NEGATED_KK,
43
117
  'iu',
44
118
  ),
45
119
  ]);
@@ -114,7 +114,9 @@ export function parseHousingFloorFromText(value) {
114
114
  if (valid(floor, total)) return { floor, totalFloors: total };
115
115
  }
116
116
 
117
- const notLetter = '(?!н|ей|ів|ност|ка|ки|s)';
117
+ // "li" excludes Uzbek "N qavatli" (an N-storey building), which states the
118
+ // building's total floor count, not which floor this unit is on.
119
+ const notLetter = '(?!н|ей|ів|ност|ка|ки|s|li)';
118
120
  const single = t.match(new RegExp(`(\\d{1,2})[^\\S\\r\\n]*-?[^\\S\\r\\n]*(?:го|ом|ым|ой|ий|nd|rd|th|st|й|м|е)?[^\\S\\r\\n]*${floorWord}${notLetter}`)) || t.match(new RegExp(`${floorWord}\\s*[:№#]?\\s*(\\d{1,2})\\b`));
119
121
  if (single) {
120
122
  const floor = Number(single[1]);
@@ -132,6 +134,17 @@ export function parseHousingFloorFromText(value) {
132
134
  const total = Number(bare[2]);
133
135
  if (floor >= 1 && floor <= 40 && total >= 2 && total <= 40 && floor <= total) return { floor, totalFloors: total };
134
136
  }
137
+
138
+ // A building's total floor count ("8 qavatli uy", "этажность: 9") is still
139
+ // worth reporting even when no unit floor is stated at all.
140
+ const totalOnly =
141
+ t.match(/(?:этажность|этажей|поверхови|поверховість|qavatlar(?:\s*soni)?|qavatli|қабатты?)\D{0,6}(\d{1,2})/) ||
142
+ t.match(/([1-9]\d?)\s*-?\s*(?:этажн[а-яё]*|поверхов[а-яіїґ]*|qavatli|қабатты?)\s*(?:дом|здани|будин|uy|bino)?/i);
143
+ if (totalOnly) {
144
+ const total = Number(totalOnly[1]);
145
+ if (total >= 1 && total <= 200) return { floor: null, totalFloors: total };
146
+ }
147
+
135
148
  return { floor: null, totalFloors: null };
136
149
  }
137
150
 
@@ -15,18 +15,32 @@ const CATEGORY = String.raw`(?:`
15
15
  + String.raw`|(?:apartments?|flats?|houses?|rooms?)\s+for\s+(?:rent|sale)`
16
16
  + String.raw`|rentals?|daily\s+rentals?`
17
17
  + String.raw`|ijaraga\s+berish|ijara|sotuvi`
18
+ // ro: "închiriere pe termen lung/scurt", "apartamente de vânzare" (subject
19
+ // leads here, so this alt embeds its own subject like the English "for
20
+ // rent/sale" form above), bare "închiriere"/"chirie"/"vânzare"
21
+ + String.raw`|(?:închiri\p{L}*|inchiri\p{L}*)\s+pe\s+termen\s+(?:lung|scurt)`
22
+ + String.raw`|(?:apartament\p{L}*|cas[aă]\p{L}*|case\p{L}*)\s+de\s+(?:vânzare|vanzare|închiriat|inchiriat)`
23
+ + String.raw`|închiri\p{L}*|inchiri\p{L}*|chirie\p{L}*|vânz\p{L}*|vanz\p{L}*`
24
+ // kk: "жалдау"/"жалға"/"жалға беру" (long rent), "ұзақ мерзімге жалдау"
25
+ // (long rent, spelled out), "тәуліктік жалдау" (short rent), "сату"/"сатылым"
26
+ + String.raw`|ұзақ\s+мерзімге\s+жалд\p{L}*|тәулік\p{L}*\s+жалд\p{L}*`
27
+ + String.raw`|жалд\p{L}*|жалғ\p{L}*(?:\s+беру\p{L}*)?|сат\p{L}*`
18
28
  + String.raw`)`;
19
29
 
20
30
  const SUBJECT = String.raw`(?:`
21
31
  + String.raw`квартир\p{L}*|кімнат\p{L}*|комнат\p{L}*|будинк\p{L}*|будинків|дом\p{L}*|житл\p{L}*|нерухомост\p{L}*|недвижимост\p{L}*`
22
32
  + String.raw`|apartments?|flats?|houses?|rooms?|property|real\s+estate`
23
33
  + String.raw`|kvartira\p{L}*|uylar|xona\p{L}*`
34
+ + String.raw`|apartament\p{L}*|cameră\p{L}*|camera\p{L}*|cas[aă]\p{L}*|case\p{L}*|locuinț\p{L}*|locuinta\p{L}*`
35
+ + String.raw`|пәтер\p{L}*|бөлме\p{L}*|үй\p{L}*`
24
36
  + String.raw`)`;
25
37
 
26
- // Only a single trailing locality clause counts as part of the heading — one
27
- // short comma- or dash-separated fragment such as ", Подільський район".
28
- // Anything richer means the title is saying something about this property.
29
- const LOCALITY_TAIL = String.raw`(?:\s*[,–—-]\s*[^\r\n,]{1,40})?`;
38
+ // Trailing locality clauses, comma- or dash-separated, such as
39
+ // ", Подільський район, біля станції метро". Each clause is capped in length
40
+ // so this stays a location tail rather than swallowing real content — but a
41
+ // digit anywhere still wins first via SPECIFIC_DETAIL, which is the actual
42
+ // guard against misreading a real title as generic.
43
+ const LOCALITY_TAIL = String.raw`(?:\s*[,–—-]\s*[^\r\n,]{1,40}){0,3}`;
30
44
 
31
45
  const GENERIC_TITLE_PATTERN = new RegExp(
32
46
  `^\\s*${CATEGORY}(?:\\s+${SUBJECT})?${LOCALITY_TAIL}\\s*$`,
package/src/housing.js CHANGED
@@ -219,7 +219,10 @@ export function resolveHousingPropertyType(value) {
219
219
  const flat = PROPERTY_TYPES.find((entry) => entry.canonical === 'flat');
220
220
  if (flat && findCanonical(text, [flat], { partial: true })) return 'flat';
221
221
  const genericUzbekHome = /(?:^|[^\p{L}\p{N}_])(?:uy|уй)(?=$|[^\p{L}\p{N}_])/iu.test(text);
222
- const explicitHouse = /(?:hovli|xovli|ҳовли|ховли|house|casa|dom|villa|будин|коттедж|вілл|вилл|(?:^|[^\p{L}\p{N}_])(?:дом|үй)(?=$|[^\p{L}\p{N}_]))/iu.test(text);
222
+ // "dom" is only 3 letters and matches as a bare substring of unrelated
223
+ // English words (e.g. "seldom", "random"), so it needs the same token
224
+ // boundary already applied to its Cyrillic counterpart "дом" below.
225
+ const explicitHouse = /(?:hovli|xovli|ҳовли|ховли|house|casa|villa|будин|коттедж|вілл|вилл|(?:^|[^\p{L}\p{N}_])(?:дом|үй|dom)(?=$|[^\p{L}\p{N}_]))/iu.test(text);
223
226
  if (genericUzbekHome && !explicitHouse) return null;
224
227
  return findCanonical(text, PROPERTY_TYPES, { partial: true })?.canonical || null;
225
228
  }
package/src/index.js CHANGED
@@ -43,6 +43,7 @@ export * from './housing-structured.js';
43
43
  export * from './hiring.js';
44
44
  export * from './hiring-languages.js';
45
45
  export * from './hiring-context.js';
46
+ export { parseHiringContext } from './hiring-context-compat.js';
46
47
  export * from './money.js';
47
48
  export * from './hiring-professions.js';
48
49
  export * from './hiring-advanced.js';
package/src/money-core.js CHANGED
@@ -5,17 +5,51 @@ import {
5
5
  NUMBER_MULTIPLIERS,
6
6
  } from './money-lexicon.js';
7
7
 
8
- export const MONEY_NUMBER_PATTERN = '\\d{1,3}(?:[ \\u00a0.,]\\d{3})+|\\d+(?:[.,]\\d+)?';
8
+ // Monetary values in job descriptions commonly combine thousands grouping with
9
+ // decimals (e.g. 137,000.00 or 137.000,00). Keep the grouped variants ahead of
10
+ // the generic decimal form so a range parser consumes the complete endpoint.
11
+ export const MONEY_NUMBER_PATTERN = '(?:\\d{1,3}(?:[ \\u00a0]\\d{3})+(?:[.,]\\d+)?|\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?|\\d+(?:[.,]\\d+)?)';
9
12
  export const MONEY_SCALE_PATTERN = 'k|к|тыс\\.?|тысяч(?:а|и)?|тис\\.?|thousand|ming|мың|m|м|млн\\.?|mln|million|миллион(?:ов)?|мільйон(?:ів)?|bn|млрд|mlrd|billion';
10
- export const MONEY_RANGE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?\\s*(?:-|–|—|до|to|bis|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?`, 'iu');
13
+ // Each scale group needs the token-boundary guard MONEY_SINGLE_RE already has
14
+ // below: without it, "2 до 3 месяцев" reads "м" off "месяцев" as the million
15
+ // abbreviation and turns 3 into 3,000,000. The boundary is nested inside the
16
+ // optional group (rather than placed after it) so a scale match that fails
17
+ // the boundary check simply falls back to "no scale" instead of failing the
18
+ // whole alternative — otherwise a no-space separator like "5до10" (no scale
19
+ // present at all) would stop matching, since "до" doesn't satisfy the
20
+ // boundary either.
21
+ export const MONEY_RANGE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(?:(${MONEY_SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-|–|—|до|to|bis|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${MONEY_SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`, 'iu');
11
22
  export const MONEY_SINGLE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?(?=$|[^\\p{L}\\p{N}_])`, 'giu');
12
23
 
13
24
  export function parseNumericAmount(raw) {
14
25
  let value = String(raw || '').replace(/\u00a0/g, ' ').trim();
15
26
  if (!value) return null;
16
- const grouped = /\d[ ,.]\d{3}(?:[ ,.]\d{3})*/.test(value);
17
- if (grouped) value = value.replace(/[ ,.]/g, '');
18
- else value = value.replace(/\s+/g, '').replace(',', '.');
27
+
28
+ // Spaces are unambiguous thousands separators in supported salary formats.
29
+ value = value.replace(/\s+/g, '');
30
+
31
+ const lastComma = value.lastIndexOf(',');
32
+ const lastDot = value.lastIndexOf('.');
33
+ if (lastComma >= 0 && lastDot >= 0) {
34
+ // When both separators are present, the final separator is decimal and the
35
+ // other one is grouping: 137,000.00 / 137.000,00.
36
+ const decimal = lastComma > lastDot ? ',' : '.';
37
+ const grouping = decimal === ',' ? /\./g : /,/g;
38
+ value = value.replace(grouping, '');
39
+ if (decimal === ',') value = value.replace(',', '.');
40
+ } else {
41
+ const separator = lastComma >= 0 ? ',' : lastDot >= 0 ? '.' : null;
42
+ if (separator) {
43
+ const escaped = separator === '.' ? '\\.' : ',';
44
+ const groupingRe = new RegExp(`^\\d{1,3}(?:${escaped}\\d{3})+$`);
45
+ if (groupingRe.test(value)) {
46
+ value = value.split(separator).join('');
47
+ } else if (separator === ',') {
48
+ value = value.replace(',', '.');
49
+ }
50
+ }
51
+ }
52
+
19
53
  const parsed = Number(value);
20
54
  return Number.isFinite(parsed) ? parsed : null;
21
55
  }
package/src/money.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { findPhoneLikeSpans } from './contact.js';
2
2
  import { aliasesOf, findCanonical, normalizeUnicode } from './normalization.js';
3
3
  import {
4
+ CURRENCY_SYMBOL_CANDIDATES,
4
5
  CURRENCY_TERMS,
5
6
  NUMBER_MULTIPLIERS,
6
7
  SALARY_MODIFIERS,
@@ -26,13 +27,22 @@ export {
26
27
  parseScaledAmount,
27
28
  } from './money-core.js';
28
29
 
29
- const CONTACT_MARKER_RE = /(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
30
+ // A leading boundary keeps "тел"/"phone" etc. from matching as a suffix of an
31
+ // unrelated word ("хостел", "котел", "telegraph"), which would wrongly mark a
32
+ // following phone-like number as a protected contact span.
33
+ const CONTACT_MARKER_RE = /(?<![\p{L}\p{N}_])(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
34
+ const JOBS_I18N_PERIOD_RE = /\bjobs\.per(hour|day|shift|week|month|year|project|piece)\b/iu;
30
35
 
31
36
  function hasSalaryContext(text) {
32
37
  return /(?:salary|зарплат|з\s*п\b|оплат|ставк|доход|оклад|компенсац|maosh|oylik|ish\s+haqi|жалақы|айлық|еңбекақы|salariu|оплата)/iu.test(text);
33
38
  }
34
39
 
35
40
  function periodFromText(text) {
41
+ // Some vacancy sources leak untranslated i18n keys into salary strings,
42
+ // e.g. "$208K/jobs.perWeek". Treat those markers as first-class periods
43
+ // instead of letting consumers fall back to an incorrect monthly salary.
44
+ const jobsMarker = String(text || '').match(JOBS_I18N_PERIOD_RE);
45
+ if (jobsMarker?.[1]) return jobsMarker[1].toLowerCase();
36
46
  return findCanonical(text, SALARY_PERIODS, { partial: true })?.canonical || null;
37
47
  }
38
48
 
@@ -61,10 +71,23 @@ function moneyContextScore(text, start, end, scaled = false) {
61
71
  return score;
62
72
  }
63
73
 
74
+ function rangeSearchText(text) {
75
+ // MONEY_RANGE_RE intentionally parses numeric structure only. Currency symbols
76
+ // may legally repeat around both endpoints ("$55 — $65"); blank them with
77
+ // equal-length whitespace so the range parser can see the numbers while all
78
+ // original indices still line up with phone protection/context scoring.
79
+ let normalized = text;
80
+ for (const symbol of Object.keys(CURRENCY_SYMBOL_CANDIDATES)) {
81
+ normalized = normalized.split(symbol).join(' '.repeat(symbol.length));
82
+ }
83
+ return normalized;
84
+ }
85
+
64
86
  function bestRange(text, protectedSpans) {
65
87
  const ranges = new RegExp(MONEY_RANGE_RE.source, 'giu');
66
88
  const candidates = [];
67
- for (const match of text.matchAll(ranges)) {
89
+ const searchable = rangeSearchText(text);
90
+ for (const match of searchable.matchAll(ranges)) {
68
91
  const start = match.index ?? 0;
69
92
  const end = start + match[0].length;
70
93
  if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
@@ -124,7 +124,11 @@ const DISTRICT_CONTEXT_ALIASES = Object.freeze({
124
124
 
125
125
  const NUMBERED_CONTEXT = '(?:tumani|тумани|district|район|massiv|массив)';
126
126
  const NUMBERED_SUFFIX_CONTEXT = '(?:chi|чи|й|квартал|kvartal|hudud(?:da)?|худуд(?:да)?)';
127
- const DISTRICT_MARKER = '(?:район|туман\\p{L}*|tumani|district)';
127
+ // The open \p{L}* suffix used to swallow any trailing letters, so
128
+ // "туманность" (an unrelated word) satisfied this as if "туман" (district)
129
+ // had been mentioned. {0,4} covers real case endings (туманда, туманидан)
130
+ // while excluding unrelated 5+-letter continuations.
131
+ const DISTRICT_MARKER = '(?:район|туман\\p{L}{0,4}|tumani|district)';
128
132
 
129
133
  function normalizedAlternatives(values) {
130
134
  return values