@whiteslove/parsing-lexicon 0.10.7 → 0.10.9

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.10.7",
3
+ "version": "0.10.9",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -368,8 +368,11 @@ export function canonicalSkillName(value) {
368
368
  return CANONICAL_BY_ALIAS.get(normalized) ?? extractSkillNames(normalized)[0]
369
369
  }
370
370
 
371
+ const PAY_BONUS_KPI_RE = /(?:\+|plus|плюс)\s*(?:%\s*\/?\s*)?kpi(?![\p{L}\p{N}_])/giu
372
+
371
373
  export function extractSkillDetails(text) {
372
- const normalized = normalizeSkillText(cleanHiringSourceText(text))
374
+ // "+ KPI" / "plus KPI" after a pay figure is a bonus scheme, not a skill.
375
+ const normalized = normalizeSkillText(cleanHiringSourceText(text).replace(PAY_BONUS_KPI_RE, ' '))
373
376
  const found = []
374
377
  const names = new Set()
375
378
  for (const { definition, patterns } of COMPILED_SKILLS) {
@@ -76,8 +76,8 @@ export function extractJobStructuredField(value, key, maxLength = 220) {
76
76
  return extractField(value, JOB_FIELD_TERMS[key], FIELD_EXTRA_ALIASES.job[key] || [], maxLength);
77
77
  }
78
78
 
79
- const INLINE_VACANCY_HEADING_RE = /✔️?\s*([A-ZА-ЯЁЎҚҒҲІЇЄ][A-ZА-ЯЁЎҚҒҲІЇЄ\s()\/-]{2,100}?)(?=\s*(?:[•⏳✔📍✈📞]|$))/gu;
80
- const SHARED_HIRING_CONTACT_RE = /(?:✈️?\s*Telegram\s*:|📞\s*Контакт\s*:)/iu;
79
+ const INLINE_VACANCY_HEADING_RE = /(?:✔️?|▶️?)\s*([A-ZА-ЯЁЎҚҒҲІЇЄ][A-ZА-ЯЁЎҚҒҲІЇЄ\s()\/-]{2,100}?(?:\s*\([^)\n]{1,30}\))?)(?=\s*(?:[:•⏳✔▶📍✈📞💰]|$))/gu;
80
+ const SHARED_HIRING_CONTACT_RE = /(?:✈️?\s*Telegram\s*:|📞\s*Контакт\s*:|👍\s*ГРАФИК\s+РАБОТЫ)/iu;
81
81
 
82
82
  /** Split a multi-vacancy source post into role-local blocks and shared context. */
83
83
  export function splitHiringVacancyEntries(value) {
@@ -31,18 +31,29 @@ export const LOCATION_LIST_KEYS = Object.freeze([
31
31
  'searchClusters',
32
32
  ]);
33
33
 
34
+ // One shared accessor compiles an entry's alias regex on first use. Giving
35
+ // every entry its own getter closure cost ~100 bytes per entry, and the full
36
+ // dictionary set holds over 130,000 entries. The compiled regex lives in a
37
+ // WeakMap because entries are frozen.
38
+ const COMPILED_RE = new WeakMap();
39
+ const ENTRY_PROTOTYPE = Object.freeze({
40
+ get re() {
41
+ let re = COMPILED_RE.get(this);
42
+ if (!re) {
43
+ re = aliasesToRegex(this.aliases);
44
+ COMPILED_RE.set(this, re);
45
+ }
46
+ return re;
47
+ },
48
+ });
49
+
50
+ function frozenEntry(fields) {
51
+ return Object.freeze(Object.assign(Object.create(ENTRY_PROTOTYPE), fields));
52
+ }
53
+
34
54
  export function locationEntry(name, ...aliases) {
35
55
  const all = [...new Set([name, ...aliases].flat().filter(Boolean))];
36
- let re = null;
37
- return Object.freeze({
38
- canonical: name,
39
- name,
40
- aliases: Object.freeze(all),
41
- get re() {
42
- re ||= aliasesToRegex(all);
43
- return re;
44
- },
45
- });
56
+ return frozenEntry({ canonical: name, name, aliases: Object.freeze(all) });
46
57
  }
47
58
 
48
59
  export function locationEntries(rows = []) {
@@ -61,6 +72,26 @@ function entryData(entry) {
61
72
  return data;
62
73
  }
63
74
 
75
+ function uniqueTruthy(values) {
76
+ if (values.length > 32) return new Set(values).size === values.length && values.every(Boolean);
77
+ for (let i = 0; i < values.length; i += 1) {
78
+ if (!values[i]) return false;
79
+ for (let j = 0; j < i; j += 1) if (values[j] === values[i]) return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ // Merging one entry with nothing returns an entry equal to itself when it is
85
+ // already frozen, canonical, untyped and carries a unique alias list that
86
+ // names it (every locationEntry()/mergeEntry() result does). Nearly every
87
+ // group at import time is a single such entry, so reuse it instead of
88
+ // rebuilding it. Map-data marking is by identity, so it is preserved as is.
89
+ function isFinishedEntry(entry) {
90
+ if (!Object.isFrozen(entry) || !entry.canonical || entry.type !== undefined || entry.entityType !== undefined) return false;
91
+ const aliases = entry.aliases;
92
+ return Array.isArray(aliases) && Object.isFrozen(aliases) && aliases.includes(entry.name) && uniqueTruthy(aliases);
93
+ }
94
+
64
95
  function mergeEntry(existing, incoming) {
65
96
  const aliases = [...new Set([
66
97
  ...(existing?.aliases || []),
@@ -73,19 +104,14 @@ function mergeEntry(existing, incoming) {
73
104
  // aliases. It remains fallback-only only when every merged source is map
74
105
  // data; otherwise the reviewed owner must retain its matching precedence.
75
106
  const mapData = existing ? isMapDataEntry(existing) && isMapDataEntry(incoming) : isMapDataEntry(incoming);
76
- let re = null;
77
- const result = {
107
+ const result = frozenEntry({
78
108
  ...base,
79
109
  canonical: base.canonical || base.name,
80
110
  type: base.type || base.entityType,
81
111
  aliases: Object.freeze(aliases),
82
- get re() {
83
- re ||= aliasesToRegex(aliases);
84
- return re;
85
- },
86
- };
112
+ });
87
113
  if (mapData) MAP_DATA_ENTRIES.add(result);
88
- return Object.freeze(result);
114
+ return result;
89
115
  }
90
116
 
91
117
  function parentKey(entry) {
@@ -111,6 +137,10 @@ export function mergeLocationEntries(...lists) {
111
137
  const result = [];
112
138
  for (const canonical of order) {
113
139
  const group = groups.get(canonical) || [];
140
+ if (group.length === 1) {
141
+ result.push(isFinishedEntry(group[0]) ? group[0] : mergeEntry(null, group[0]));
142
+ continue;
143
+ }
114
144
  const scopedParents = [...new Set(group.map(parentKey).filter(Boolean))];
115
145
 
116
146
  if (scopedParents.length <= 1) {
package/src/money-core.js CHANGED
@@ -22,7 +22,10 @@ export const MONEY_SCALE_PATTERN = 'k|к|тыс\\.?|тысяч(?:а|и)?|тис\
22
22
  // present at all) would stop matching, since "до" doesn't satisfy the
23
23
  // boundary either.
24
24
  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');
25
- export const MONEY_SINGLE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?(?=$|[^\\p{L}\\p{N}_])`, 'giu');
25
+ // Boards often glue the currency to the number ("10 000 000сум"); without this
26
+ // the boundary check fails on the full number and backtracks to "10 000".
27
+ const ATTACHED_CURRENCY = "сум|сўм|so['’ʻʼ`]?m|som|uzs|usd|eur|rub|руб|грн|kzt|kgs";
28
+ export const MONEY_SINGLE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?(?=$|[^\\p{L}\\p{N}_]|(?:${ATTACHED_CURRENCY})(?![\\p{L}\\p{N}_]))`, 'giu');
26
29
 
27
30
  export function parseNumericAmount(raw) {
28
31
  let value = String(raw || '').replace(/\u00a0/g, ' ').trim();
package/src/money.js CHANGED
@@ -1,4 +1,4 @@
1
- import { findPhoneLikeSpans } from './contact.js';
1
+ import { findPhoneLikeSpans, parsePhoneNumbers } from './contact.js';
2
2
  import { aliasesOf, findCanonical, normalizeUnicode } from './normalization.js';
3
3
  import {
4
4
  CURRENCY_SYMBOL_CANDIDATES,
@@ -56,7 +56,15 @@ function hasModifier(text, key) {
56
56
  function protectedPhoneSpans(text) {
57
57
  return findPhoneLikeSpans(text).filter((span) => {
58
58
  const prefix = text.slice(Math.max(0, span.start - 32), span.start);
59
- return CONTACT_MARKER_RE.test(prefix);
59
+ if (CONTACT_MARKER_RE.test(prefix)) return true;
60
+ // A label-less international number ("998 953 506 090", typically scraped
61
+ // into a pay field) is a contact, not a 12-digit salary. Require a valid
62
+ // E.164 number of 11+ digits so ordinary grouped salaries stay money.
63
+ // A "998 95… - 998 95…" pair is merged into one span, so test each part.
64
+ return span.raw.split(/\s*[-–—]\s*/u).some((part) => {
65
+ const digits = part.replace(/\D/g, '');
66
+ return digits.length >= 11 && parsePhoneNumbers(`+${digits}`).length > 0;
67
+ });
60
68
  });
61
69
  }
62
70
 
@@ -10,8 +10,8 @@ const DASHES_RE = /[‐‑‒–—―]/g;
10
10
  // recent long inputs. Short strings (dictionary aliases, single words) are
11
11
  // cheaper to normalize than to cache and are left alone; the cap bounds
12
12
  // memory to a few recent texts per function.
13
- const MEMO_MIN_LENGTH = 200;
14
- const MEMO_MAX_ENTRIES = 32;
13
+ const MEMO_MIN_LENGTH = 24;
14
+ const MEMO_MAX_ENTRIES = 512;
15
15
 
16
16
  function memoizeLongText(fn) {
17
17
  const cache = new Map();
@@ -34,8 +34,15 @@ export const normalizeUnicode = memoizeLongText(function normalizeUnicode(value)
34
34
  .replace(DASHES_RE, '-');
35
35
  });
36
36
 
37
- /** Stable comparison form for parser dictionaries. */
38
- export const normalizeForMatch = memoizeLongText(function normalizeForMatch(value) {
37
+ // Dictionary construction normalizes the same names and aliases hundreds of
38
+ // thousands of times (about 5x more calls than distinct strings at import), so
39
+ // short strings get a bounded cache of their own. Once it is full new strings
40
+ // are simply computed, never evicted: the entries that fill it first are the
41
+ // dictionary strings that every later match compares against.
42
+ const SHORT_CACHE_MAX_ENTRIES = 131072;
43
+ const shortMatchCache = new Map();
44
+
45
+ function normalizeForMatchUncached(value) {
39
46
  return normalizeUnicode(value)
40
47
  .toLocaleLowerCase()
41
48
  .replace(/ё/g, 'е')
@@ -43,7 +50,19 @@ export const normalizeForMatch = memoizeLongText(function normalizeForMatch(valu
43
50
  .replace(/[^\p{L}\p{N}]+/gu, ' ')
44
51
  .replace(/\s+/g, ' ')
45
52
  .trim();
46
- });
53
+ }
54
+
55
+ const normalizeForMatchLong = memoizeLongText(normalizeForMatchUncached);
56
+
57
+ /** Stable comparison form for parser dictionaries. */
58
+ export function normalizeForMatch(value) {
59
+ if (typeof value !== 'string' || value.length >= MEMO_MIN_LENGTH) return normalizeForMatchLong(value);
60
+ const hit = shortMatchCache.get(value);
61
+ if (hit !== undefined) return hit;
62
+ const result = normalizeForMatchUncached(value);
63
+ if (shortMatchCache.size < SHORT_CACHE_MAX_ENTRIES) shortMatchCache.set(value, result);
64
+ return result;
65
+ }
47
66
 
48
67
  /** Search-only apostrophe compaction for Uzbek/Karakalpak variants. */
49
68
  const normalizeCompactApostropheForMatch = memoizeLongText(function normalizeCompactApostropheForMatch(value) {
@@ -87,39 +106,50 @@ const KARAKALPAK_LATIN_PATTERN_EQUIVALENCE = Object.freeze({
87
106
  u: '[uú]', ú: '[uú]',
88
107
  });
89
108
 
109
+ // Every map below is keyed by single BMP characters, so one regex replace is
110
+ // identical to mapping the string character by character.
111
+ function foldByMap(map) {
112
+ const re = new RegExp(`[${Object.keys(map).join('')}]`, 'g');
113
+ return (text) => text.replace(re, (char) => map[char]);
114
+ }
115
+ const foldCyrillicChars = foldByMap(CYRILLIC_SEARCH_MAP);
116
+ const foldKazakhChars = foldByMap(KAZAKH_SEARCH_EQUIVALENCE);
117
+ const foldKarakalpakChars = foldByMap(KARAKALPAK_LATIN_SEARCH_EQUIVALENCE);
118
+
90
119
  /** Search-oriented Cyrillic folding; canonical identity still comes from explicit aliases. */
91
120
  export const foldCyrillicForSearch = memoizeLongText(function foldCyrillicForSearch(value) {
92
- return normalizeUnicode(value)
93
- .toLocaleLowerCase()
94
- .split('')
95
- .map((char) => CYRILLIC_SEARCH_MAP[char] ?? char)
96
- .join('');
121
+ return foldCyrillicChars(normalizeUnicode(value).toLocaleLowerCase());
97
122
  });
98
123
 
99
124
  const foldKazakhForSearch = memoizeLongText(function foldKazakhForSearch(value) {
100
- return normalizeUnicode(value)
101
- .toLocaleLowerCase()
102
- .split('')
103
- .map((char) => KAZAKH_SEARCH_EQUIVALENCE[char] ?? char)
104
- .join('');
125
+ return foldKazakhChars(normalizeUnicode(value).toLocaleLowerCase());
105
126
  });
106
127
 
107
- function foldKarakalpakLatinForSearch(value) {
108
- return normalizeUnicode(value)
109
- .toLocaleLowerCase()
110
- .split('')
111
- .map((char) => KARAKALPAK_LATIN_SEARCH_EQUIVALENCE[char] ?? char)
112
- .join('');
113
- }
128
+ const foldKarakalpakLatinForSearch = memoizeLongText(function foldKarakalpakLatinForSearch(value) {
129
+ return foldKarakalpakChars(normalizeUnicode(value).toLocaleLowerCase());
130
+ });
114
131
 
115
- export function normalizedAliasKeys(value, { transliteration = true } = {}) {
132
+ function computeAliasKeys(value, transliteration) {
116
133
  const forms = transliteration
117
134
  ? [value, foldCyrillicForSearch(value), foldKazakhForSearch(value), foldKarakalpakLatinForSearch(value)]
118
135
  : [value];
119
- return [...new Set(forms.flatMap((form) => [
136
+ return Object.freeze([...new Set(forms.flatMap((form) => [
120
137
  normalizeForMatch(form),
121
138
  normalizeCompactApostropheForMatch(form),
122
- ]).filter(Boolean))];
139
+ ]).filter(Boolean))]);
140
+ }
141
+
142
+ // One parse asks every matcher for the keys of the same text, and each
143
+ // derivation folds the text through four scripts. Keyed by text, per mode.
144
+ const aliasKeysWithTransliteration = memoizeLongText((value) => computeAliasKeys(value, true));
145
+ const aliasKeysDirect = memoizeLongText((value) => computeAliasKeys(value, false));
146
+
147
+ function aliasKeys(value, transliteration) {
148
+ return (transliteration ? aliasKeysWithTransliteration : aliasKeysDirect)(value);
149
+ }
150
+
151
+ export function normalizedAliasKeys(value, { transliteration = true } = {}) {
152
+ return [...aliasKeys(value, transliteration)];
123
153
  }
124
154
 
125
155
  export function aliasesOf(entry) {
@@ -137,7 +167,7 @@ function createAliasIndex(entries, { transliteration = true } = {}) {
137
167
  const index = new Map();
138
168
  for (const entry of entries || []) {
139
169
  for (const value of valuesOf(entry)) {
140
- for (const key of normalizedAliasKeys(value, { transliteration })) {
170
+ for (const key of aliasKeys(value, transliteration)) {
141
171
  if (!index.has(key)) index.set(key, entry);
142
172
  }
143
173
  }
@@ -206,6 +236,32 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
206
236
  return index;
207
237
  }
208
238
 
239
+ const PARTIAL_INFO = new WeakMap();
240
+
241
+ // An alias key is letters and digits joined by single spaces, and so is a text
242
+ // key, so `${alias}` is contained in the padded text exactly when the alias
243
+ // equals a run of whole words of the text. That lets a partial lookup probe the
244
+ // text's word n-grams instead of scanning every alias in the index. `ordinal`
245
+ // keeps each alias's position in the index, which is what orders a tie.
246
+ function partialInfo(index) {
247
+ let info = PARTIAL_INFO.get(index);
248
+ if (!info) {
249
+ const ordinal = new Map();
250
+ let maxWords = 1;
251
+ let position = 0;
252
+ for (const alias of index.keys()) {
253
+ ordinal.set(alias, position);
254
+ position += 1;
255
+ let words = 1;
256
+ for (let i = alias.indexOf(' '); i !== -1; i = alias.indexOf(' ', i + 1)) words += 1;
257
+ if (words > maxWords) maxWords = words;
258
+ }
259
+ info = { ordinal, maxWords };
260
+ PARTIAL_INFO.set(index, info);
261
+ }
262
+ return info;
263
+ }
264
+
209
265
  /**
210
266
  * Every entry tied for the longest partial alias match, in the order they
211
267
  * first reach that length. Exact matches always resolve unambiguously to a
@@ -216,25 +272,36 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
216
272
  export function findCanonicalCandidates(value, entries, { partial = false, transliteration = true } = {}) {
217
273
  if (!value) return [];
218
274
  const index = getAliasIndex(entries, { transliteration });
219
- for (const key of normalizedAliasKeys(value, { transliteration })) {
275
+ const textKeys = aliasKeys(value, transliteration);
276
+ for (const key of textKeys) {
220
277
  const exact = index.get(key);
221
278
  if (exact) return [exact];
222
279
  }
223
280
  if (!partial) return [];
224
281
 
225
- const textKeys = normalizedAliasKeys(value, { transliteration });
226
- let tied = [];
227
- let bestLength = 0;
228
- for (const [alias, entry] of index) {
229
- if (alias.length < bestLength) continue;
230
- if (!textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) continue;
231
- if (alias.length > bestLength) {
232
- bestLength = alias.length;
233
- tied = [entry];
234
- } else if (!tied.includes(entry)) {
235
- tied.push(entry);
282
+ const { ordinal, maxWords } = partialInfo(index);
283
+ const matched = new Map();
284
+ for (const key of textKeys) {
285
+ const words = key.split(' ');
286
+ for (let start = 0; start < words.length; start += 1) {
287
+ let phrase = '';
288
+ const stop = Math.min(words.length, start + maxWords);
289
+ for (let end = start; end < stop; end += 1) {
290
+ phrase = end === start ? words[end] : `${phrase} ${words[end]}`;
291
+ const position = ordinal.get(phrase);
292
+ if (position !== undefined) matched.set(phrase, position);
293
+ }
236
294
  }
237
295
  }
296
+
297
+ let bestLength = 0;
298
+ for (const alias of matched.keys()) if (alias.length > bestLength) bestLength = alias.length;
299
+ const longest = [...matched].filter(([alias]) => alias.length === bestLength).sort((a, b) => a[1] - b[1]);
300
+ const tied = [];
301
+ for (const [alias] of longest) {
302
+ const entry = index.get(alias);
303
+ if (!tied.includes(entry)) tied.push(entry);
304
+ }
238
305
  return tied;
239
306
  }
240
307