@whiteslove/parsing-lexicon 0.9.8 → 0.9.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/README.md +38 -1
- package/package.json +1 -1
- package/src/alias-prefilter.js +37 -10
- package/src/central-asia-locations.js +7 -4
- package/src/currency.d.ts +1 -0
- package/src/currency.js +1 -0
- package/src/housing-money.js +32 -19
- package/src/housing-structured.js +10 -10
- package/src/housing-text.js +12 -6
- package/src/housing.js +1 -1
- package/src/locations-runtime.js +3 -1
- package/src/money-core.d.ts +1 -0
- package/src/money-core.js +35 -5
- package/src/money-lexicon.js +2 -1
- package/src/money.js +1 -0
- package/src/normalization.js +51 -12
package/README.md
CHANGED
|
@@ -85,7 +85,44 @@ parseHiringContext(
|
|
|
85
85
|
);
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
Subpath exports are available for consumers that need narrower modules, including `./geo`, `./locations`, `./housing-context`, `./housing-structured`, `./hiring-context`, `./hiring-professions` and `./
|
|
88
|
+
Subpath exports are available for consumers that need narrower modules, including `./geo`, `./locations`, `./housing-context`, `./housing-structured`, `./hiring-context`, `./hiring-professions`, `./housing-money`, `./money` and `./currency`.
|
|
89
|
+
|
|
90
|
+
### Money & currency
|
|
91
|
+
|
|
92
|
+
Currency and magnitude vocabulary (symbols, ISO codes, spelled-out names, and
|
|
93
|
+
regional stand-ins like "у.е.") lives here once, in `money-lexicon.js`. A
|
|
94
|
+
consumer that needs to know whether some text mentions money at all — a fast
|
|
95
|
+
pre-filter before running the heavier parsers below, e.g. when scanning many
|
|
96
|
+
HTML card candidates scraped from a page — should use `moneyMentionPattern()`
|
|
97
|
+
rather than hand-copying a currency symbol list. A hand-copied list silently
|
|
98
|
+
drifts from this one; that exact bug (a missing "₸"/KZT symbol, and a missing
|
|
99
|
+
"у.е." token) is why this section exists.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
import {
|
|
103
|
+
moneyCurrencyPattern, // just the currency alternation, e.g. for building a custom matcher
|
|
104
|
+
moneyMentionPattern, // currency OR a bare magnitude word ("15 млн") — boundary-guarded
|
|
105
|
+
moneyCurrencyFromText, // -> 'KZT' | 'USD' | ... | null
|
|
106
|
+
} from '@whiteslove/parsing-lexicon/currency';
|
|
107
|
+
import { parseHousingPrice } from '@whiteslove/parsing-lexicon/housing-money';
|
|
108
|
+
import { parseSalary } from '@whiteslove/parsing-lexicon/money';
|
|
109
|
+
|
|
110
|
+
const looksLikeMoney = new RegExp(moneyMentionPattern(), 'iu');
|
|
111
|
+
looksLikeMoney.test('300 000 ₸ в месяц'); // true
|
|
112
|
+
looksLikeMoney.test('2 до 3 месяцев'); // false — "м" inside "месяцев" does not count
|
|
113
|
+
|
|
114
|
+
moneyCurrencyFromText('300 000 ₸ в месяц'); // 'KZT'
|
|
115
|
+
|
|
116
|
+
parseHousingPrice('12 500 000 сум', 'UZS');
|
|
117
|
+
// -> { amount: 12500000, currency: 'UZS' }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Do not write a source-local regex for "does this text contain a price" or
|
|
121
|
+
"what currency is this" — both already exist here and are kept in sync with
|
|
122
|
+
the full currency/magnitude vocabulary used by `parseHousingPrice`/
|
|
123
|
+
`parseSalary`. If a currency form you need is missing, add it to
|
|
124
|
+
`CURRENCY_TERMS` in `money-lexicon.js` (and `CURRENCY_SYMBOL_CANDIDATES` if
|
|
125
|
+
it's a standalone symbol) rather than working around the gap in the consumer.
|
|
89
126
|
|
|
90
127
|
## Data-quality rules
|
|
91
128
|
|
package/package.json
CHANGED
package/src/alias-prefilter.js
CHANGED
|
@@ -107,6 +107,41 @@ function indexFor(entries) {
|
|
|
107
107
|
return index;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Every GRAM-length window of `text`, precomputed once for reuse across lists. */
|
|
111
|
+
export function computeTextGrams(text) {
|
|
112
|
+
return textGrams(String(text || ''));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function candidateIndices(entries, value, grams) {
|
|
116
|
+
const { byGram, always } = indexFor(entries);
|
|
117
|
+
const candidates = new Set(always);
|
|
118
|
+
for (const gram of grams) {
|
|
119
|
+
const bucket = byGram.get(gram);
|
|
120
|
+
if (!bucket) continue;
|
|
121
|
+
for (const i of bucket) candidates.add(i);
|
|
122
|
+
}
|
|
123
|
+
return [...candidates].sort((a, b) => a - b);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every entry in `entries` (original list order) the text could plausibly
|
|
128
|
+
* contain, per the gram index — a filter, not a verdict. Callers still run
|
|
129
|
+
* their own verification (regex, exact-token match, ...) on what comes back;
|
|
130
|
+
* see the module doc for why this is safe even for non-regex verifiers.
|
|
131
|
+
*
|
|
132
|
+
* `grams`, from `computeTextGrams()`, lets a caller scanning the same text
|
|
133
|
+
* against many lists compute the O(text length) gram pass once instead of
|
|
134
|
+
* once per list.
|
|
135
|
+
*/
|
|
136
|
+
export function candidateEntries(entries, text, grams) {
|
|
137
|
+
if (!Array.isArray(entries) || !entries.length) return [];
|
|
138
|
+
const value = String(text || '');
|
|
139
|
+
if (!value) return [];
|
|
140
|
+
|
|
141
|
+
const indices = candidateIndices(entries, value, grams || textGrams(value));
|
|
142
|
+
return indices.map((i) => entries[i]);
|
|
143
|
+
}
|
|
144
|
+
|
|
110
145
|
/**
|
|
111
146
|
* First entry in `entries` whose alias regex matches `text`.
|
|
112
147
|
*
|
|
@@ -124,16 +159,8 @@ export function matchFirstEntry(entries, text, accept) {
|
|
|
124
159
|
const value = String(text || '');
|
|
125
160
|
if (!value) return undefined;
|
|
126
161
|
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
for (const gram of textGrams(value)) {
|
|
130
|
-
const bucket = byGram.get(gram);
|
|
131
|
-
if (!bucket) continue;
|
|
132
|
-
for (const i of bucket) candidates.add(i);
|
|
133
|
-
}
|
|
134
|
-
if (!candidates.size) return undefined;
|
|
135
|
-
|
|
136
|
-
for (const i of [...candidates].sort((a, b) => a - b)) {
|
|
162
|
+
const indices = candidateIndices(entries, value, textGrams(value));
|
|
163
|
+
for (const i of indices) {
|
|
137
164
|
const entry = entries[i];
|
|
138
165
|
// `re` carries no /g flag, so exec() is stateless and safe to reuse here.
|
|
139
166
|
const match = entry?.re?.exec(value);
|
|
@@ -2,6 +2,7 @@ import { LOCATION_DICTIONARIES } from './locations-runtime.js';
|
|
|
2
2
|
import { isMapDataEntry, LOCATION_LIST_KEYS } from './location-merge.js';
|
|
3
3
|
import { CITIES_BY_COUNTRY, canonicalCity } from './geography.js';
|
|
4
4
|
import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
|
|
5
|
+
import { candidateEntries, computeTextGrams } from './alias-prefilter.js';
|
|
5
6
|
import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_SEARCH_CLUSTERS } from './kz-location-extensions.js';
|
|
6
7
|
import { UZ_AMBIGUOUS_LOCAL_NAMES } from './uz-location-extensions.js';
|
|
7
8
|
|
|
@@ -237,12 +238,13 @@ function mapDataMatch(value, normalizedValue, item) {
|
|
|
237
238
|
return null;
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
function findEntryMatches(text, cityName, data, { includeMapData = false } = {}) {
|
|
241
|
+
function findEntryMatches(text, cityName, data, { includeMapData = false, grams = null } = {}) {
|
|
241
242
|
const value = String(text || '');
|
|
242
243
|
const normalizedValue = normalizeForMatch(value);
|
|
244
|
+
const textGrams = grams || computeTextGrams(value);
|
|
243
245
|
const raw = [];
|
|
244
246
|
for (const key of LOCATION_LIST_KEYS) {
|
|
245
|
-
for (const item of data?.[key] || []) {
|
|
247
|
+
for (const item of candidateEntries(data?.[key] || [], value, textGrams)) {
|
|
246
248
|
if (isMapDataEntry(item) && !includeMapData) continue;
|
|
247
249
|
const match = (isMapDataEntry(item) ? mapDataMatch(value, normalizedValue, item) : value.match(item?.re))
|
|
248
250
|
|| (key === 'residentialComplexes' ? markedResidentialMatch(value, item) : null);
|
|
@@ -329,16 +331,17 @@ export function matchCentralAsiaLocationEntities(text, countryCode, preferredCit
|
|
|
329
331
|
const preferred = canonicalCity(preferredCity, countryCode) || preferredCity;
|
|
330
332
|
const explicit = explicitCityFromText(text, countryCode);
|
|
331
333
|
const scopedCity = preferred && country[preferred] ? preferred : explicit && country[explicit] ? explicit : null;
|
|
334
|
+
const grams = computeTextGrams(text);
|
|
332
335
|
|
|
333
336
|
if (scopedCity) {
|
|
334
|
-
const matches = findEntryMatches(text, scopedCity, country[scopedCity], { includeMapData: true });
|
|
337
|
+
const matches = findEntryMatches(text, scopedCity, country[scopedCity], { includeMapData: true, grams });
|
|
335
338
|
const clusters = clusterMatches(matches, countryCode);
|
|
336
339
|
return Object.freeze({ city: scopedCity, matches: Object.freeze(matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
|
|
337
340
|
}
|
|
338
341
|
|
|
339
342
|
const byCity = [];
|
|
340
343
|
for (const [cityName, data] of Object.entries(country)) {
|
|
341
|
-
const matches = findEntryMatches(text, cityName, data);
|
|
344
|
+
const matches = findEntryMatches(text, cityName, data, { grams });
|
|
342
345
|
if (matches.length) byCity.push({ city: cityName, matches });
|
|
343
346
|
}
|
|
344
347
|
|
package/src/currency.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const CURRENCY_SYMBOL_CANDIDATES: Readonly<Record<string, readonly string
|
|
|
9
9
|
export function moneyCurrencyCandidatesFromText(value: unknown): readonly string[];
|
|
10
10
|
export function moneyCurrencyFromText(value: unknown, fallbackCurrency?: string | null): string | null;
|
|
11
11
|
export function moneyCurrencyPattern(): string;
|
|
12
|
+
export function moneyMentionPattern(): string;
|
|
12
13
|
export function currencyDisplay(code: unknown, locale?: string): CurrencyDisplay | null;
|
|
13
14
|
export function currencySymbol(code: unknown, locale?: string): string | null;
|
|
14
15
|
export function currencyName(code: unknown, locale?: string): string | null;
|
package/src/currency.js
CHANGED
package/src/housing-money.js
CHANGED
|
@@ -62,6 +62,34 @@ const COMMON_HOUSING_STRUCTURE_PATTERNS = Object.freeze([
|
|
|
62
62
|
/(?:^|[^\p{L}\p{N}_])\d{1,5}(?:[.,]\d{1,2})?\s*(?:м(?:2|²)|m(?:2|²)|sqm|sq\.?\s*m|м\s*кв\.?)\s*(?=$|[^\p{L}\p{N}_])/giu,
|
|
63
63
|
]);
|
|
64
64
|
|
|
65
|
+
// These five patterns are built once here rather than inside
|
|
66
|
+
// extractHousingMoneyCandidates(): they depend only on the module-level
|
|
67
|
+
// constants above (PRICE_KEYWORD/MONEY_NUMBER_PATTERN/SCALE_PATTERN/
|
|
68
|
+
// PRICE_CURRENCY_*), never on the text being parsed, yet that function runs
|
|
69
|
+
// once per listing card. Constructing them with `new RegExp(...)` per call
|
|
70
|
+
// forces V8 to recompile a pattern that embeds moneyCurrencyPattern()'s
|
|
71
|
+
// ~250-branch currency alternation under the unicode ('u') flag from
|
|
72
|
+
// scratch every time; on a catalogue page with dozens of cards this
|
|
73
|
+
// repeated compilation of a large unicode-mode regex is expensive enough to
|
|
74
|
+
// exhaust the heap (observed as an OOM inside V8's regex code generation).
|
|
75
|
+
// Reusing the same compiled RegExp objects removes that per-call cost —
|
|
76
|
+
// they carry the /g flag but are only ever driven through matchAll(), which
|
|
77
|
+
// takes its own internal copy and never mutates lastIndex on the original.
|
|
78
|
+
const EXPANDED_UZBEK_THOUSANDS_RE = new RegExp(
|
|
79
|
+
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
80
|
+
'igu',
|
|
81
|
+
);
|
|
82
|
+
const LABELLED_PRICE_RANGE_RE = new RegExp(
|
|
83
|
+
`${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?(?=$|[^\\p{L}\\p{N}_])`,
|
|
84
|
+
'igu',
|
|
85
|
+
);
|
|
86
|
+
const LABELLED_SCALE_RE = new RegExp(
|
|
87
|
+
`${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
|
|
88
|
+
'igu',
|
|
89
|
+
);
|
|
90
|
+
const PRICE_AMOUNT_AFTER_CURRENCY_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*[.]?\\s*${PRICE_CURRENCY_AFTER_NUMBER}`, 'igu');
|
|
91
|
+
const PRICE_AMOUNT_BEFORE_CURRENCY_RE = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
|
|
92
|
+
|
|
65
93
|
function moneyParsingContext(value = '') {
|
|
66
94
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
67
95
|
const country = canonicalCountryCode(value.country) || '';
|
|
@@ -239,11 +267,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
239
267
|
// amount, while `2 million 500` is a single split-million amount rather
|
|
240
268
|
// than two competing prices. These must be extracted before the shorter
|
|
241
269
|
// generic currency/scale candidates below.
|
|
242
|
-
const
|
|
243
|
-
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})(?:[.]|[\\s\\u00a0])000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
244
|
-
'igu',
|
|
245
|
-
);
|
|
246
|
-
for (const match of text.matchAll(expandedUzbekThousandsRe)) {
|
|
270
|
+
for (const match of text.matchAll(EXPANDED_UZBEK_THOUSANDS_RE)) {
|
|
247
271
|
const start = match.index ?? 0;
|
|
248
272
|
addCandidate({
|
|
249
273
|
amount: Number(match[1]) * 1000,
|
|
@@ -262,11 +286,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
262
286
|
// lower bound through the legacy single-price result. It must be collected
|
|
263
287
|
// before generic amounts so the second endpoint cannot be selected merely
|
|
264
288
|
// because it is larger.
|
|
265
|
-
const
|
|
266
|
-
`${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?(?=$|[^\\p{L}\\p{N}_])`,
|
|
267
|
-
'igu',
|
|
268
|
-
);
|
|
269
|
-
for (const match of text.matchAll(labelledPriceRangeRe)) {
|
|
289
|
+
for (const match of text.matchAll(LABELLED_PRICE_RANGE_RE)) {
|
|
270
290
|
// A scale stated on only one endpoint applies to both: "50-60 тыс сум"
|
|
271
291
|
// means 50,000-60,000, not 50-60,000. Mirrors money.js's range parsing.
|
|
272
292
|
const firstScale = match[2] || match[4] || null;
|
|
@@ -310,11 +330,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
310
330
|
// glyph. In Uzbek listing prose, "narxi 850 ming" conventionally means
|
|
311
331
|
// 850,000 UZS; retaining `scale` prevents a later generic fallback from
|
|
312
332
|
// mistaking the base number for USD.
|
|
313
|
-
const
|
|
314
|
-
`${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
|
|
315
|
-
'igu',
|
|
316
|
-
);
|
|
317
|
-
for (const match of text.matchAll(labelledScaleRe)) {
|
|
333
|
+
for (const match of text.matchAll(LABELLED_SCALE_RE)) {
|
|
318
334
|
const start = match.index ?? 0;
|
|
319
335
|
const end = start + match[0].length;
|
|
320
336
|
const scale = match[2];
|
|
@@ -339,10 +355,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
|
|
|
339
355
|
});
|
|
340
356
|
}
|
|
341
357
|
|
|
342
|
-
for (const regex of [
|
|
343
|
-
new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*[.]?\\s*${PRICE_CURRENCY_AFTER_NUMBER}`, 'igu'),
|
|
344
|
-
new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu'),
|
|
345
|
-
]) {
|
|
358
|
+
for (const regex of [PRICE_AMOUNT_AFTER_CURRENCY_RE, PRICE_AMOUNT_BEFORE_CURRENCY_RE]) {
|
|
346
359
|
for (const match of text.matchAll(regex)) {
|
|
347
360
|
const amount = parseNumericAmount(match[1]);
|
|
348
361
|
const start = match.index ?? 0; const end = start + match[0].length;
|
|
@@ -19,16 +19,16 @@ import {
|
|
|
19
19
|
} from './housing-numeric-spans.js';
|
|
20
20
|
|
|
21
21
|
const NUMBER_WORDS = Object.freeze([
|
|
22
|
-
[/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|bir\s+xona(?:li)?|бир\s+хона(?:ли|лик)?|1\s*(?:-\s*)?к(?:омн\p{L}*)?|1\s*(?:-\s*)?xona(?:li)?|1\s*(?:-\s*)?хона(?:лик|ли)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
|
|
23
|
-
[/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|ikki\s+xona(?:li)?|икки\s+хона(?:ли|лик)?|2\s*(?:-\s*)?к(?:омн\p{L}*)?|2\s*(?:-\s*)?xona(?:li)?|2\s*(?:-\s*)?хона(?:лик|ли)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
|
|
24
|
-
[/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|uch\s+xona(?:li)?|уч\s+хона(?:ли|лик)?|3\s*(?:-\s*)?к(?:омн\p{L}*)?|3\s*(?:-\s*)?xona(?:li)?|3\s*(?:-\s*)?хона(?:лик|ли)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
|
|
25
|
-
[/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|to['’]?rt\s+xona(?:li)?|tort\s+xona(?:li)?|тўрт\s+хона(?:ли|лик)?|турт\s+хона(?:ли|лик)?|4\s*(?:-\s*)?к(?:омн\p{L}*)?|4\s*(?:-\s*)?xona(?:li)?|4\s*(?:-\s*)?хона(?:лик|ли)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
|
|
26
|
-
[/(?<![\p{L}\p{N}_])(?:пятикомнатн\p{L}*|besh\s+xona(?:li)?|беш\s+хона(?:ли|лик)?|5\s*(?:-\s*)?к(?:омн\p{L}*)?|5\s*(?:-\s*)?xona(?:li)?|5\s*(?:-\s*)?хона(?:лик|ли)?|five[- ]bedroom|five[- ]room)(?![\p{L}\p{N}_])/iu, 5],
|
|
27
|
-
[/(?<![\p{L}\p{N}_])(?:шестикомнатн\p{L}*|olti\s+xona(?:li)?|олти\s+хона(?:ли|лик)?|6\s*(?:-\s*)?к(?:омн\p{L}*)?|6\s*(?:-\s*)?xona(?:li)?|6\s*(?:-\s*)?хона(?:лик|ли)?|six[- ]bedroom|six[- ]room)(?![\p{L}\p{N}_])/iu, 6],
|
|
28
|
-
[/(?<![\p{L}\p{N}_])(?:семикомнатн\p{L}*|yetti\s+xona(?:li)?|етти\s+хона(?:ли|лик)?|7\s*(?:-\s*)?к(?:омн\p{L}*)?|7\s*(?:-\s*)?xona(?:li)?|7\s*(?:-\s*)?хона(?:лик|ли)?|seven[- ]bedroom|seven[- ]room)(?![\p{L}\p{N}_])/iu, 7],
|
|
29
|
-
[/(?<![\p{L}\p{N}_])(?:восьмикомнатн\p{L}*|sakkiz\s+xona(?:li)?|саккиз\s+хона(?:ли|лик)?|8\s*(?:-\s*)?к(?:омн\p{L}*)?|8\s*(?:-\s*)?xona(?:li)?|8\s*(?:-\s*)?хона(?:лик|ли)?|eight[- ]bedroom|eight[- ]room)(?![\p{L}\p{N}_])/iu, 8],
|
|
30
|
-
[/(?<![\p{L}\p{N}_])(?:девятикомнатн\p{L}*|to['’]?qqiz\s+xona(?:li)?|toqqiz\s+xona(?:li)?|тўққиз\s+хона(?:ли|лик)?|токкиз\s+хона(?:ли|лик)?|9\s*(?:-\s*)?к(?:омн\p{L}*)?|9\s*(?:-\s*)?xona(?:li)?|9\s*(?:-\s*)?хона(?:лик|ли)?|nine[- ]bedroom|nine[- ]room)(?![\p{L}\p{N}_])/iu, 9],
|
|
31
|
-
[/(?<![\p{L}\p{N}_])(?:десятикомнатн\p{L}*|o['’]?n\s+xona(?:li)?|on\s+xona(?:li)?|ўн\s+хона(?:ли|лик)?|он\s+хона(?:ли|лик)?|10\s*(?:-\s*)?к(?:омн\p{L}*)?|10\s*(?:-\s*)?xona(?:li)?|10\s*(?:-\s*)?хона(?:лик|ли)?|ten[- ]bedroom|ten[- ]room)(?![\p{L}\p{N}_])/iu, 10],
|
|
22
|
+
[/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|однокімнатн\p{L}*|bir\s+xona(?:li)?|бир\s+хона(?:ли|лик)?|1\s*(?:-\s*)?к(?:омн\p{L}*)?|1\s*(?:-\s*)?xona(?:li)?|1\s*(?:-\s*)?хона(?:лик|ли)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
|
|
23
|
+
[/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|двокімнатн\p{L}*|ikki\s+xona(?:li)?|икки\s+хона(?:ли|лик)?|2\s*(?:-\s*)?к(?:омн\p{L}*)?|2\s*(?:-\s*)?xona(?:li)?|2\s*(?:-\s*)?хона(?:лик|ли)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
|
|
24
|
+
[/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|трикімнатн\p{L}*|uch\s+xona(?:li)?|уч\s+хона(?:ли|лик)?|3\s*(?:-\s*)?к(?:омн\p{L}*)?|3\s*(?:-\s*)?xona(?:li)?|3\s*(?:-\s*)?хона(?:лик|ли)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
|
|
25
|
+
[/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|чотирикімнатн\p{L}*|to['’]?rt\s+xona(?:li)?|tort\s+xona(?:li)?|тўрт\s+хона(?:ли|лик)?|турт\s+хона(?:ли|лик)?|4\s*(?:-\s*)?к(?:омн\p{L}*)?|4\s*(?:-\s*)?xona(?:li)?|4\s*(?:-\s*)?хона(?:лик|ли)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
|
|
26
|
+
[/(?<![\p{L}\p{N}_])(?:пятикомнатн\p{L}*|п['’]ятикімнатн\p{L}*|besh\s+xona(?:li)?|беш\s+хона(?:ли|лик)?|5\s*(?:-\s*)?к(?:омн\p{L}*)?|5\s*(?:-\s*)?xona(?:li)?|5\s*(?:-\s*)?хона(?:лик|ли)?|five[- ]bedroom|five[- ]room)(?![\p{L}\p{N}_])/iu, 5],
|
|
27
|
+
[/(?<![\p{L}\p{N}_])(?:шестикомнатн\p{L}*|шестикімнатн\p{L}*|olti\s+xona(?:li)?|олти\s+хона(?:ли|лик)?|6\s*(?:-\s*)?к(?:омн\p{L}*)?|6\s*(?:-\s*)?xona(?:li)?|6\s*(?:-\s*)?хона(?:лик|ли)?|six[- ]bedroom|six[- ]room)(?![\p{L}\p{N}_])/iu, 6],
|
|
28
|
+
[/(?<![\p{L}\p{N}_])(?:семикомнатн\p{L}*|семикімнатн\p{L}*|yetti\s+xona(?:li)?|етти\s+хона(?:ли|лик)?|7\s*(?:-\s*)?к(?:омн\p{L}*)?|7\s*(?:-\s*)?xona(?:li)?|7\s*(?:-\s*)?хона(?:лик|ли)?|seven[- ]bedroom|seven[- ]room)(?![\p{L}\p{N}_])/iu, 7],
|
|
29
|
+
[/(?<![\p{L}\p{N}_])(?:восьмикомнатн\p{L}*|восьмикімнатн\p{L}*|sakkiz\s+xona(?:li)?|саккиз\s+хона(?:ли|лик)?|8\s*(?:-\s*)?к(?:омн\p{L}*)?|8\s*(?:-\s*)?xona(?:li)?|8\s*(?:-\s*)?хона(?:лик|ли)?|eight[- ]bedroom|eight[- ]room)(?![\p{L}\p{N}_])/iu, 8],
|
|
30
|
+
[/(?<![\p{L}\p{N}_])(?:девятикомнатн\p{L}*|дев['’]ятикімнатн\p{L}*|to['’]?qqiz\s+xona(?:li)?|toqqiz\s+xona(?:li)?|тўққиз\s+хона(?:ли|лик)?|токкиз\s+хона(?:ли|лик)?|9\s*(?:-\s*)?к(?:омн\p{L}*)?|9\s*(?:-\s*)?xona(?:li)?|9\s*(?:-\s*)?хона(?:лик|ли)?|nine[- ]bedroom|nine[- ]room)(?![\p{L}\p{N}_])/iu, 9],
|
|
31
|
+
[/(?<![\p{L}\p{N}_])(?:десятикомнатн\p{L}*|десятикімнатн\p{L}*|o['’]?n\s+xona(?:li)?|on\s+xona(?:li)?|ўн\s+хона(?:ли|лик)?|он\s+хона(?:ли|лик)?|10\s*(?:-\s*)?к(?:омн\p{L}*)?|10\s*(?:-\s*)?xona(?:li)?|10\s*(?:-\s*)?хона(?:лик|ли)?|ten[- ]bedroom|ten[- ]room)(?![\p{L}\p{N}_])/iu, 10],
|
|
32
32
|
]);
|
|
33
33
|
|
|
34
34
|
// Common classifieds shorthand: rooms/floor/total floors, optionally followed
|
package/src/housing-text.js
CHANGED
|
@@ -75,23 +75,29 @@ export function parseHousingResidentialComplex(value) {
|
|
|
75
75
|
return /[a-zA-Zа-яёіїґ]{2,}/i.test(name) ? name : null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
const AREA_UNIT = String.raw`(?:m2|m²|мкв|м2|м²|sq\s?m|кв\.?\s*м|квадрат[а-яёіїґ]*(?:\s*м(?:етр(?:а|ов)?)?)?)`;
|
|
79
|
+
// Built once: parseHousingAreaFromText() runs per listing card, and neither
|
|
80
|
+
// pattern depends on the text being parsed (see the CURRENCY_ALT comment in
|
|
81
|
+
// housing-money.js for why rebuilding a regex on every call is costly).
|
|
82
|
+
const AREA_LABELLED_RE = new RegExp(
|
|
83
|
+
String.raw`(?:общая\s+площадь|площадь\s+общая|total\s+area|umumiy\s+maydon|suprafa(?:ță|ta)\s+total(?:ă|a)?)\s*[:=\-]?\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*${AREA_UNIT}`,
|
|
84
|
+
'iu',
|
|
85
|
+
);
|
|
86
|
+
const AREA_GENERIC_RE = new RegExp(String.raw`(?<![\d.,])(\d{1,4}(?:[.,]\d{1,2})?)\s*${AREA_UNIT}`, 'iu');
|
|
87
|
+
|
|
78
88
|
export function parseHousingAreaFromText(value) {
|
|
79
89
|
const text = String(value || '');
|
|
80
90
|
if (!text) return null;
|
|
81
91
|
|
|
82
|
-
const unit = String.raw`(?:m2|m²|мкв|м2|м²|sq\s?m|кв\.?\s*м|квадрат[а-яёіїґ]*(?:\s*м(?:етр(?:а|ов)?)?)?)`;
|
|
83
92
|
const toArea = (raw) => {
|
|
84
93
|
const number = Number(String(raw || '').replace(/\s+/g, '').replace(',', '.'));
|
|
85
94
|
return Number.isFinite(number) && number > 0 && number <= 100000 ? number : null;
|
|
86
95
|
};
|
|
87
96
|
|
|
88
|
-
const labelled = text.match(
|
|
89
|
-
String.raw`(?:общая\s+площадь|площадь\s+общая|total\s+area|umumiy\s+maydon|suprafa(?:ță|ta)\s+total(?:ă|a)?)\s*[:=\-]?\s*(\d{1,4}(?:[.,]\d{1,2})?)\s*${unit}`,
|
|
90
|
-
'iu',
|
|
91
|
-
));
|
|
97
|
+
const labelled = text.match(AREA_LABELLED_RE);
|
|
92
98
|
if (labelled) return toArea(labelled[1]);
|
|
93
99
|
|
|
94
|
-
const generic = text.match(
|
|
100
|
+
const generic = text.match(AREA_GENERIC_RE);
|
|
95
101
|
if (generic) return toArea(generic[1]);
|
|
96
102
|
|
|
97
103
|
const compact = text.match(/(?:^|\n)[^\d\r\n]{0,8}[1-9]\s*[¹²³⁴⁵⁶⁷⁸⁹]?\s*\/\s*[0-9]{1,2}\s*\/\s*[0-9]{1,2}\s+(\d{2,4}(?:[.,]\d{1,2})?)\s*кв(?=\s|$)/im);
|
package/src/housing.js
CHANGED
|
@@ -210,7 +210,7 @@ export function looksHousingRoomOnly(value) {
|
|
|
210
210
|
if (!text) return false;
|
|
211
211
|
const occupancy = resolveHousingOccupancy(text);
|
|
212
212
|
if (occupancy === 'room' || occupancy === 'sharedRoom' || occupancy === 'bedSpace') return true;
|
|
213
|
-
return /подселени|підселен|комнату\s
|
|
213
|
+
return /подселени|підселен|комнату\s+в(?![\p{L}\p{N}_])|кімнату\s+в(?![\p{L}\p{N}_])|сда[её]тся\s+комната|сдается\s+комната|сдам\s+комнату|здам\s+кімнат|room\s+in\s+a\s+(?:shared\s+)?flat|room\s+for\s+rent|shared\s+(?:flat|apartment|room)|roommate|flatmate|xona\s+ijaraga|xona\s+beriladi|sherik(?:ka|lik)|шерик(?:ка|лик)|(?:1|бир)\s*та\s*(?:бола|киши|қиз|киз)\s*керак|1\s*хонага[^\r\n]{0,40}(?:киши|одам)\s*турилади|бөлме\s+жалға|închiriez\s+camer[ăa]|ищу[^\r\n]{0,60}сосед|ищем[^\r\n]{0,60}сосед|нужен[^\r\n]{0,60}сосед|нужна[^\r\n]{0,60}сосед|шукаю[^\r\n]{0,60}сусід|шукаємо[^\r\n]{0,60}сусід|потрібен[^\r\n]{0,60}сусід|потрібна[^\r\n]{0,60}сусід|співмешкан|співжител|соседк|сусідк/iu.test(text);
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
export function resolveHousingPropertyType(value) {
|
package/src/locations-runtime.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { isMapDataEntry, LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
|
|
9
9
|
import { canonicalCity } from './geography.js';
|
|
10
10
|
import { normalizeForMatch } from './normalization.js';
|
|
11
|
+
import { candidateEntries, computeTextGrams } from './alias-prefilter.js';
|
|
11
12
|
import { KG_LOCATION_EXTENSIONS } from './kg-location-extensions.js';
|
|
12
13
|
import { KG_BISHKEK_AREA_EXTENSIONS } from './kg-bishkek-area-extensions.js';
|
|
13
14
|
import { KG_BISHKEK_STREET_EXTENSIONS } from './kg-bishkek-street-extensions.js';
|
|
@@ -180,11 +181,12 @@ export function matchDictionaryLocation(text, countryCode, city = null) {
|
|
|
180
181
|
const cities = canonical && country[canonical] ? [[canonical, country[canonical]]] : Object.entries(country);
|
|
181
182
|
const value = String(text || '');
|
|
182
183
|
const normalizedValue = normalizeForMatch(value);
|
|
184
|
+
const grams = computeTextGrams(value);
|
|
183
185
|
let best = null;
|
|
184
186
|
|
|
185
187
|
for (const [cityName, data] of cities) {
|
|
186
188
|
for (const type of LOCATION_LIST_KEYS) {
|
|
187
|
-
for (const entry of data[type] || []) {
|
|
189
|
+
for (const entry of candidateEntries(data[type] || [], value, grams)) {
|
|
188
190
|
const mapData = isMapDataEntry(entry);
|
|
189
191
|
const match = mapData ? mapDataMatch(entry, normalizedValue) : entry?.re?.exec(value);
|
|
190
192
|
if (!match) continue;
|
package/src/money-core.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ export function parseScaledAmount(raw: unknown, scale?: unknown): number | null;
|
|
|
8
8
|
export function moneyCurrencyCandidatesFromText(value: unknown): readonly string[];
|
|
9
9
|
export function moneyCurrencyFromText(value: unknown, fallbackCurrency?: string | null): string | null;
|
|
10
10
|
export function moneyCurrencyPattern(): string;
|
|
11
|
+
export function moneyMentionPattern(): string;
|
package/src/money-core.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { aliasesOf, escapeRegex, findCanonical } from './normalization.js';
|
|
1
|
+
import { aliasesOf, escapeRegex, findCanonical, findCanonicalCandidates } from './normalization.js';
|
|
2
2
|
import {
|
|
3
3
|
CURRENCY_SYMBOL_CANDIDATES,
|
|
4
4
|
CURRENCY_TERMS,
|
|
@@ -67,11 +67,25 @@ export function parseScaledAmount(raw, scale) {
|
|
|
67
67
|
return value == null ? null : value * moneyScaleMultiplier(scale);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// A term like "сом" can be a partial-match tie between multiple currencies
|
|
71
|
+
// (e.g. Kyrgyzstani "сом"/KGS and Uzbek "so'm"/UZS both fold to the search
|
|
72
|
+
// key "som"). Returns every tied currency, in registration order, so callers
|
|
73
|
+
// can break the tie using their own context instead of always keeping
|
|
74
|
+
// whichever currency happens to be registered first in the lexicon.
|
|
75
|
+
function explicitCurrencyCandidates(value) {
|
|
71
76
|
const text = String(value || '');
|
|
72
77
|
// Ambiguous glyphs are removed so they cannot hide an explicit ISO/name token.
|
|
73
78
|
const lexicalText = text.replace(/[$¥¥]/g, ' ');
|
|
74
|
-
return
|
|
79
|
+
return findCanonicalCandidates(lexicalText, CURRENCY_TERMS, { partial: true })
|
|
80
|
+
.map((entry) => entry?.canonical)
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function explicitCurrencyFromText(value, fallbackCurrency = null) {
|
|
85
|
+
const candidates = explicitCurrencyCandidates(value);
|
|
86
|
+
if (!candidates.length) return null;
|
|
87
|
+
const fallback = String(fallbackCurrency || '').trim().toUpperCase();
|
|
88
|
+
return fallback && candidates.includes(fallback) ? fallback : candidates[0];
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
export function moneyCurrencyCandidatesFromText(value) {
|
|
@@ -81,7 +95,7 @@ export function moneyCurrencyCandidatesFromText(value) {
|
|
|
81
95
|
if (currency && !candidates.includes(currency)) candidates.push(currency);
|
|
82
96
|
};
|
|
83
97
|
|
|
84
|
-
|
|
98
|
+
explicitCurrencyCandidates(text).forEach(add);
|
|
85
99
|
|
|
86
100
|
for (const [symbol, currencies] of Object.entries(CURRENCY_SYMBOL_CANDIDATES)) {
|
|
87
101
|
if (!text.includes(symbol)) continue;
|
|
@@ -96,7 +110,7 @@ export function moneyCurrencyCandidatesFromText(value) {
|
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
export function moneyCurrencyFromText(value, fallbackCurrency = null) {
|
|
99
|
-
const explicit = explicitCurrencyFromText(value);
|
|
113
|
+
const explicit = explicitCurrencyFromText(value, fallbackCurrency);
|
|
100
114
|
if (explicit) return explicit;
|
|
101
115
|
|
|
102
116
|
const candidates = moneyCurrencyCandidatesFromText(value);
|
|
@@ -112,3 +126,19 @@ export function moneyCurrencyPattern() {
|
|
|
112
126
|
.map(escapeRegex)
|
|
113
127
|
.join('|');
|
|
114
128
|
}
|
|
129
|
+
|
|
130
|
+
// A cheap "does this text mention money at all" pre-filter — a currency term
|
|
131
|
+
// or a bare magnitude word (e.g. "15 млн", no currency spelled out) both
|
|
132
|
+
// count. Consumers that need to reject non-money text fast before running a
|
|
133
|
+
// full parseSalary/parseHousingPrice pass (e.g. scanning many HTML card
|
|
134
|
+
// candidates) can test against this instead of hand-copying a currency list,
|
|
135
|
+
// which drifts from the lexicon's own (see MONEY_SCALE_PATTERN for the
|
|
136
|
+
// magnitude half already used by MONEY_RANGE_RE/MONEY_SINGLE_RE).
|
|
137
|
+
//
|
|
138
|
+
// Word-boundary guarded for the same reason MONEY_RANGE_RE/MONEY_SINGLE_RE
|
|
139
|
+
// are: MONEY_SCALE_PATTERN includes bare single-letter abbreviations like "м"
|
|
140
|
+
// (million) that would otherwise match inside an unrelated word — e.g. "2 до
|
|
141
|
+
// 3 месяцев" ("months") must never register as a money mention.
|
|
142
|
+
export function moneyMentionPattern() {
|
|
143
|
+
return `(?<![\\p{L}\\p{N}_])(?:${moneyCurrencyPattern()}|${MONEY_SCALE_PATTERN})(?![\\p{L}\\p{N}_])`;
|
|
144
|
+
}
|
package/src/money-lexicon.js
CHANGED
|
@@ -6,7 +6,7 @@ export const CURRENCY_TERMS = Object.freeze([
|
|
|
6
6
|
group('USD', { ru: ['$', 'usd', 'доллар', 'доллара', 'долларов', 'бакс', 'у.е.', 'у е'], en: ['$', 'usd', 'dollar', 'dollars', 'us dollar'], uk: ['$', 'usd', 'долар', 'долари', 'доларів'], ro: ['$', 'usd', 'dolar', 'dolari'], uzLatn: ['$', 'usd', 'dollar'], uzCyrl: ['$', 'доллар'], kk: ['$', 'usd', 'доллар'] }),
|
|
7
7
|
group('EUR', { ru: ['€', 'eur', 'евро'], en: ['€', 'eur', 'euro', 'euros'], uk: ['€', 'eur', 'євро'], ro: ['€', 'eur', 'euro'], uzLatn: ['€', 'eur', 'yevro'], uzCyrl: ['€', 'евро'], kk: ['€', 'eur', 'еуро'] }),
|
|
8
8
|
group('UZS', { ru: ['сум', 'сумов', 'узс'], en: ['uzs', 'uzbek som'], uk: ['uzs', 'сум'], ro: ['uzs'], uzLatn: ["so'm", 'so‘m', 'soʻm', 'sum', 'uzs'], uzCyrl: ['сўм', 'сум'], kk: ['uzs'] }),
|
|
9
|
-
group('KZT', { ru: ['тенге', 'тг', 'kzt'], en: ['kzt', 'tenge'], uk: ['тенге', 'kzt'], ro: ['kzt', 'tenge'], uzLatn: ['kzt', 'tenge'], uzCyrl: ['kzt', 'тенге'], kk: ['теңге', 'тенге', 'тг', 'kzt'] }),
|
|
9
|
+
group('KZT', { ru: ['₸', 'тенге', 'тг', 'kzt'], en: ['₸', 'kzt', 'tenge'], uk: ['₸', 'тенге', 'kzt'], ro: ['₸', 'kzt', 'tenge'], uzLatn: ['₸', 'kzt', 'tenge'], uzCyrl: ['₸', 'kzt', 'тенге'], kk: ['₸', 'теңге', 'тенге', 'тг', 'kzt'] }),
|
|
10
10
|
group('UAH', { ru: ['грн', 'гривна', 'гривны', 'гривен', '₴', 'uah'], en: ['uah', 'hryvnia', 'hryvnias', '₴'], uk: ['грн', 'гривня', 'гривні', 'гривень', '₴', 'uah'], ro: ['uah', 'hrivne', 'grivne'], uzLatn: ['uah'], uzCyrl: ['uah'], kk: ['uah'] }),
|
|
11
11
|
group('RUB', { ru: ['₽', 'руб', 'руб.', 'рубль', 'рубля', 'рублей', 'rub', 'rur'], en: ['₽', 'rub', 'rur', 'ruble', 'rubles'], uk: ['₽', 'руб', 'рублів', 'rub'], ro: ['₽', 'rub', 'ruble'], uzLatn: ['₽', 'rub', 'rubl'], uzCyrl: ['₽', 'рубль'], kk: ['₽', 'rub', 'рубль'] }),
|
|
12
12
|
group('GBP', { ru: ['£', 'gbp', 'фунт', 'фунтов'], en: ['£', 'gbp', 'pound', 'pounds', 'sterling'], uk: ['£', 'gbp', 'фунт', 'фунтів'], ro: ['£', 'gbp', 'liră sterlină', 'lira sterlina'], uzLatn: ['£', 'gbp', 'funt'], uzCyrl: ['£', 'фунт'], kk: ['£', 'gbp', 'фунт'] }),
|
|
@@ -41,6 +41,7 @@ export const CURRENCY_SYMBOL_CANDIDATES = Object.freeze({
|
|
|
41
41
|
'€': Object.freeze(['EUR']),
|
|
42
42
|
'₴': Object.freeze(['UAH']),
|
|
43
43
|
'₽': Object.freeze(['RUB']),
|
|
44
|
+
'₸': Object.freeze(['KZT']),
|
|
44
45
|
'£': Object.freeze(['GBP']),
|
|
45
46
|
'₺': Object.freeze(['TRY']),
|
|
46
47
|
'₾': Object.freeze(['GEL']),
|
package/src/money.js
CHANGED
package/src/normalization.js
CHANGED
|
@@ -183,32 +183,68 @@ export function getAliasOwnersIndex(entries, { transliteration = true } = {}) {
|
|
|
183
183
|
return index;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Every entry tied for the longest partial alias match, in the order they
|
|
188
|
+
* first reach that length. Exact matches always resolve unambiguously to a
|
|
189
|
+
* single entry. Callers that need to disambiguate a partial tie against
|
|
190
|
+
* caller-supplied context (e.g. a fallback currency) should use this instead
|
|
191
|
+
* of findCanonical(), which silently keeps only the first-registered entry.
|
|
192
|
+
*/
|
|
193
|
+
export function findCanonicalCandidates(value, entries, { partial = false, transliteration = true } = {}) {
|
|
194
|
+
if (!value) return [];
|
|
188
195
|
const index = getAliasIndex(entries, { transliteration });
|
|
189
196
|
for (const key of normalizedAliasKeys(value, { transliteration })) {
|
|
190
197
|
const exact = index.get(key);
|
|
191
|
-
if (exact) return exact;
|
|
198
|
+
if (exact) return [exact];
|
|
192
199
|
}
|
|
193
|
-
if (!partial) return
|
|
200
|
+
if (!partial) return [];
|
|
194
201
|
|
|
195
202
|
const textKeys = normalizedAliasKeys(value, { transliteration });
|
|
196
|
-
let
|
|
203
|
+
let tied = [];
|
|
197
204
|
let bestLength = 0;
|
|
198
205
|
for (const [alias, entry] of index) {
|
|
199
|
-
if (alias.length
|
|
200
|
-
if (textKeys.some((text) => ` ${text} `.includes(` ${alias} `)))
|
|
201
|
-
|
|
206
|
+
if (alias.length < bestLength) continue;
|
|
207
|
+
if (!textKeys.some((text) => ` ${text} `.includes(` ${alias} `))) continue;
|
|
208
|
+
if (alias.length > bestLength) {
|
|
202
209
|
bestLength = alias.length;
|
|
210
|
+
tied = [entry];
|
|
211
|
+
} else if (!tied.includes(entry)) {
|
|
212
|
+
tied.push(entry);
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
|
-
return
|
|
215
|
+
return tied;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function findCanonical(value, entries, options = {}) {
|
|
219
|
+
return findCanonicalCandidates(value, entries, options)[0] || null;
|
|
206
220
|
}
|
|
207
221
|
|
|
208
222
|
export function escapeRegex(value) {
|
|
209
223
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
210
224
|
}
|
|
211
225
|
|
|
226
|
+
// A word-boundary class scoped to the scripts this lexicon actually targets
|
|
227
|
+
// (Latin incl. Romanian/Karakalpak diacritics, Cyrillic incl. supplement,
|
|
228
|
+
// digits, underscore) instead of the full-Unicode \p{L}\p{N} property
|
|
229
|
+
// escapes. Under V8's unicode ('u') regex mode, \p{L}/\p{N} character
|
|
230
|
+
// classes compile to disproportionately large native code — benchmarked at
|
|
231
|
+
// roughly 20x slower and 4x more memory per compiled entry than this
|
|
232
|
+
// explicit range, which matters a lot here: aliasesToRegex()/matcherFor()
|
|
233
|
+
// compile one such regex per lexicon entry or per matcher (`entry.re`
|
|
234
|
+
// getters across the location/housing/hiring dictionaries, and
|
|
235
|
+
// findAllCanonical()'s combined matcher), and a single city's street list
|
|
236
|
+
// alone can hold thousands of entries. See the apps/flats housing-source-
|
|
237
|
+
// crawler OOM this was diagnosed against (norieltor.com.ua and others).
|
|
238
|
+
const ALIAS_BOUNDARY_CLASS = 'A-Za-z0-9_\\u00C0-\\u02AF\\u0370-\\u03FF\\u0400-\\u052F';
|
|
239
|
+
// For literal alternation (aliasesToRegex: "either start-of-string or a
|
|
240
|
+
// non-word char"), the class itself must be negated.
|
|
241
|
+
const ALIAS_NON_BOUNDARY_RE_SOURCE = `[^${ALIAS_BOUNDARY_CLASS}]`;
|
|
242
|
+
// For lookaround (matcherFor: "not preceded/followed by a word char"), the
|
|
243
|
+
// negation belongs on the lookaround itself, not inside the class too --
|
|
244
|
+
// `(?<![^X])` double-negates into "must be adjacent to X", the opposite of
|
|
245
|
+
// the intended boundary check.
|
|
246
|
+
const ALIAS_WORD_RE_SOURCE = `[${ALIAS_BOUNDARY_CLASS}]`;
|
|
247
|
+
|
|
212
248
|
function aliasPattern(value) {
|
|
213
249
|
const source = normalizeUnicode(value).trim();
|
|
214
250
|
let pattern = '';
|
|
@@ -239,7 +275,7 @@ function matcherFor(entries, { transliteration = true } = {}) {
|
|
|
239
275
|
const result = searchAliases.length
|
|
240
276
|
? Object.freeze({
|
|
241
277
|
owners,
|
|
242
|
-
re: new RegExp(`(
|
|
278
|
+
re: new RegExp(`(?<!${ALIAS_WORD_RE_SOURCE})(?:${searchAliases.map(aliasPattern).join('|')})(?!${ALIAS_WORD_RE_SOURCE})`, 'gi'),
|
|
243
279
|
})
|
|
244
280
|
: Object.freeze({ owners, re: null });
|
|
245
281
|
|
|
@@ -408,12 +444,15 @@ export function assertValidLexicon(entries, options = {}) {
|
|
|
408
444
|
return true;
|
|
409
445
|
}
|
|
410
446
|
|
|
411
|
-
export function aliasesToRegex(values, flags = '
|
|
447
|
+
export function aliasesToRegex(values, flags = 'i') {
|
|
412
448
|
const alternatives = [...new Set(values || [])]
|
|
413
449
|
.filter((value) => typeof value === 'string' && value.trim())
|
|
414
450
|
.map((value) => normalizeUnicode(value).trim())
|
|
415
451
|
.sort((a, b) => b.length - a.length)
|
|
416
452
|
.map(aliasPattern);
|
|
417
453
|
if (!alternatives.length) throw new TypeError('aliasesToRegex() requires at least one non-empty alias');
|
|
418
|
-
return new RegExp(
|
|
454
|
+
return new RegExp(
|
|
455
|
+
`(?:^|${ALIAS_NON_BOUNDARY_RE_SOURCE})(?:${alternatives.join('|')})(?:$|${ALIAS_NON_BOUNDARY_RE_SOURCE})`,
|
|
456
|
+
flags.replace('u', ''),
|
|
457
|
+
);
|
|
419
458
|
}
|