@whiteslove/parsing-lexicon 0.5.12 → 0.5.13

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.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -220,4 +220,4 @@
220
220
  "geography"
221
221
  ],
222
222
  "license": "MIT"
223
- }
223
+ }
@@ -21,9 +21,28 @@ function parseBedrooms(text) {
21
21
  }
22
22
 
23
23
  function parseBathrooms(text) {
24
- const match = text.match(/(\d)\s*(?:сан\s?уз(?:е)?л\p{L}*|с\/?у(?=$|[^\p{L}\p{N}_])|ванн[аы]|bathrooms?|sanuzel|hammom)/iu)
25
- || text.match(/(?:сан\s?уз(?:е)?л\p{L}*|bathrooms?|sanuzel|hammom)\D{0,4}(\d)/iu);
26
- return number(match, 1, 10);
24
+ const bathroom = '(?:сан\\s?уз(?:е)?л\\p{L}*|с\\/?у(?=$|[^\\p{L}\\p{N}_])|ванн[аы]|bathrooms?|sanuzel|hammom)';
25
+ const areaUnit = '(?:м²|м2|m²|m2|sqm|sq\\.?\\s*m|кв\\.?\\s*м(?:²|2)?)';
26
+
27
+ // Room-area rows such as "Санузел4.52 м²" carry the size of a singular
28
+ // bathroom, not a bathroom count. Resolve these before generic count forms
29
+ // so another nearby number cannot win.
30
+ const singularArea = text.match(new RegExp(
31
+ `(?:сан\\s?уз(?:е)?л|bathroom|sanuzel|hammom)\\s*[:=\\-–—]?\\s*\\d{1,4}(?:[.,]\\d{1,2})?\\s*${areaUnit}(?=$|[^\\p{L}\\p{N}_])`,
32
+ 'iu',
33
+ ));
34
+ if (singularArea) return 1;
35
+
36
+ const forward = text.match(new RegExp(`(\\d{1,2})\\s*${bathroom}`, 'iu'));
37
+ const forwardCount = number(forward, 1, 10);
38
+ if (forwardCount != null) return forwardCount;
39
+
40
+ const reversed = text.match(new RegExp(
41
+ `${bathroom}\\D{0,4}(\\d{1,2})(?![.,]\\d)(?!\\s*${areaUnit})`,
42
+ 'iu',
43
+ ));
44
+ const reversedCount = number(reversed, 1, 10);
45
+ return reversedCount;
27
46
  }
28
47
 
29
48
  function parseBuildingYear(text) {
@@ -191,7 +210,7 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
191
210
  /евро[- ]?(?:студи\p{L}*|двушк\p{L}*|трёшк\p{L}*|трешк\p{L}*|четырёшк\p{L}*|четрешк\p{L}*|планировк\p{L}*|[1-9](?!\p{N}))|европланировк\p{L}*|euro[- ]?layout/iu,
192
211
  ),
193
212
  gas,
194
- newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s*built|yangi\s+(?:bino|qurilgan|uy)|bloc\s+nou/iu),
213
+ newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s+built|yangi\s+(?:bino|qurilgan|uy)|bloc\s+nou/iu),
195
214
  communalSeparated: parseCommunalSeparated(text, country),
196
215
  parking: bool(
197
216
  text,
@@ -5,4 +5,5 @@ export type HousingPriceParseResult = Readonly<{
5
5
  }>;
6
6
 
7
7
  export function parseHousingPrice(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
8
- export const parsePriceFromText: typeof parseHousingPrice;
8
+ export function parseHousingPricePerSqm(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
9
+ export const parsePriceFromText: typeof parseHousingPrice;
@@ -30,6 +30,75 @@ function escapeRegex(value) {
30
30
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
31
  }
32
32
 
33
+ const SCALE_PATTERN = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
34
+ .sort((a, b) => String(b).length - String(a).length)
35
+ .map(escapeRegex)
36
+ .join('|');
37
+
38
+ const PER_SQM_UNIT = String.raw`(?:m(?:2|²)|м(?:2|²)|м\s*кв\.?|кв\.?\s*м(?:2|²)?|sqm|sq\.?\s*m|square\s+met(?:er|re)s?|квадратн\p{L}*\s+метр\p{L}*)`;
39
+ const PER_SQM_NUMBER = `(${MONEY_NUMBER_PATTERN})(?:\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`;
40
+ const PER_SQM_CURRENCY = `(?:\\s*${CURRENCY_ALT}(?![\\p{L}\\p{N}_]))?`;
41
+ const PER_SQM_AFTER_AMOUNT_RE = new RegExp(
42
+ `${PER_SQM_NUMBER}${PER_SQM_CURRENCY}\\s*(?:за\\s*(?:1\\s*)?|по\\s*|/\\s*|per\\s+)${PER_SQM_UNIT}(?=$|[^\\p{L}\\p{N}_])`,
43
+ 'igu',
44
+ );
45
+ const PER_SQM_AFTER_UNIT_RE = new RegExp(
46
+ `${PER_SQM_NUMBER}${PER_SQM_CURRENCY}\\s*${PER_SQM_UNIT}\\s*(?:uchun|учун|ga|га)(?=$|[^\\p{L}\\p{N}_])`,
47
+ 'igu',
48
+ );
49
+ const PER_SQM_BEFORE_AMOUNT_RE = new RegExp(
50
+ `(?:за\\s*(?:1\\s*)?|по\\s*|per\\s+)${PER_SQM_UNIT}\\s*[:=\\-–—]?\\s*${PER_SQM_NUMBER}${PER_SQM_CURRENCY}(?=$|[^\\p{L}\\p{N}_])`,
51
+ 'igu',
52
+ );
53
+
54
+ function parsedMoneyAmount(numberValue, scaleValue) {
55
+ const amount = scaleValue
56
+ ? parseScaledAmount(numberValue, scaleValue)
57
+ : parseNumericAmount(numberValue);
58
+ return amount != null && amount >= 1 && amount <= 5_000_000_000
59
+ ? Math.round(amount)
60
+ : null;
61
+ }
62
+
63
+ const APPROXIMATE_RE = /около|примерно|~|≈/iu;
64
+
65
+ function perSquareMeterMatches(text, fallbackCurrency = '') {
66
+ const matches = [];
67
+ const seen = new Set();
68
+
69
+ for (const regex of [PER_SQM_AFTER_AMOUNT_RE, PER_SQM_AFTER_UNIT_RE, PER_SQM_BEFORE_AMOUNT_RE]) {
70
+ regex.lastIndex = 0;
71
+ let match;
72
+ while ((match = regex.exec(text)) !== null) {
73
+ const amount = parsedMoneyAmount(match[1], match[2]);
74
+ if (amount == null) continue;
75
+ const start = match.index ?? 0;
76
+ const end = regex.lastIndex;
77
+ const key = `${start}:${end}`;
78
+ if (seen.has(key)) continue;
79
+ seen.add(key);
80
+ matches.push({
81
+ start,
82
+ end,
83
+ amount,
84
+ currency: moneyCurrencyFromText(match[0], fallbackCurrency || '') || fallbackCurrency || '',
85
+ approximate: APPROXIMATE_RE.test(match[0]),
86
+ });
87
+ }
88
+ }
89
+
90
+ return matches.sort((a, b) => a.start - b.start);
91
+ }
92
+
93
+ function maskRanges(text, ranges) {
94
+ if (!ranges.length) return text;
95
+ let masked = text;
96
+ for (const { start, end } of [...ranges].sort((a, b) => b.start - a.start)) {
97
+ masked = `${masked.slice(0, start)}${' '.repeat(Math.max(0, end - start))}${masked.slice(end)}`;
98
+ }
99
+ return masked;
100
+ }
101
+
33
102
  function isPaymentScopedAmount(text, start, end) {
34
103
  const left = text
35
104
  .slice(Math.max(0, start - 56), start)
@@ -48,7 +117,23 @@ function isPaymentScopedAmount(text, start, end) {
48
117
  && Boolean(findCanonical(right, PAYMENT_AMOUNT_TERMS, { partial: true }));
49
118
  }
50
119
 
51
- const APPROXIMATE_RE = /около|примерно|~|≈/iu;
120
+ export function parseHousingPricePerSqm(value, fallbackCurrency = '') {
121
+ const original = String(value || '');
122
+ if (!original) return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
123
+
124
+ const text = maskPhoneLikeSpans(original);
125
+ const candidates = perSquareMeterMatches(text, fallbackCurrency);
126
+ if (!candidates.length) {
127
+ return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
128
+ }
129
+
130
+ const candidate = candidates[0];
131
+ return Object.freeze({
132
+ amount: candidate.amount,
133
+ currency: candidate.currency,
134
+ approximate: candidate.approximate,
135
+ });
136
+ }
52
137
 
53
138
  export function parseHousingPrice(value, fallbackCurrency = '') {
54
139
  const original = String(value || '');
@@ -57,15 +142,22 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
57
142
  // Contact spans are removed once, before every money branch. A phone can
58
143
  // therefore never win as a labelled, currency-tagged or fallback amount.
59
144
  const text = maskPhoneLikeSpans(original);
60
- let currency = moneyCurrencyFromText(text, fallbackCurrency || '') || '';
61
- const explicit = Boolean(findCanonical(text, CURRENCY_TERMS, { partial: true }));
145
+ // Unit prices are not listing totals. Mask their exact spans before running
146
+ // the ordinary total-price parser so "От 13 млн за м2" cannot become a
147
+ // 13,000,000 UZS apartment price. The dedicated parser above still exposes
148
+ // the unit price to consumers that need it.
149
+ const priceText = maskRanges(text, perSquareMeterMatches(text, fallbackCurrency));
150
+ let currency = moneyCurrencyFromText(priceText, fallbackCurrency || '')
151
+ || moneyCurrencyFromText(text, fallbackCurrency || '')
152
+ || '';
153
+ const explicit = Boolean(findCanonical(priceText, CURRENCY_TERMS, { partial: true }));
62
154
  let price = null;
63
155
 
64
156
  // Common Ukrainian/Russian classifieds shorthand: "10 т грн" / "10 т гр".
65
157
  // Keep this housing-specific instead of adding globally ambiguous aliases
66
158
  // `т` (tonne) and `гр` (gram) to the shared money lexicon. A nearby price
67
159
  // keyword plus an explicit hryvnia shorthand makes the intent unambiguous.
68
- const compactThousandUah = text.match(new RegExp(
160
+ const compactThousandUah = priceText.match(new RegExp(
69
161
  `${PRICE_KEYWORD}[^\\r\\n]{0,48}?(\\d{1,6}(?:[.,]\\d{1,2})?)\\s*т(?:ыс\\.?)?\\s*(?:гр(?:н)?|₴|uah)(?=$|[^\\p{L}\\p{N}_])`,
70
162
  'iu',
71
163
  ));
@@ -79,7 +171,7 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
79
171
 
80
172
  // Uzbek ads also use a dot as a thousands separator after four leading
81
173
  // digits: "2500.000 сум" means 2,500,000 UZS, not 2,500 UZS.
82
- const expandedUzbekThousands = text.match(new RegExp(
174
+ const expandedUzbekThousands = priceText.match(new RegExp(
83
175
  `${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})[.]000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
84
176
  'iu',
85
177
  ));
@@ -91,10 +183,36 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
91
183
  }
92
184
  }
93
185
 
94
- const labelled = text.match(new RegExp(`${PRICE_KEYWORD}\\s*[:\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})`, 'i'));
186
+ // Split-million forms must win before a generic labelled amount. Otherwise
187
+ // "Narxi 2 миллион 500" would stop at 2,000,000 and lose the trailing 500k.
188
+ if (price == null) {
189
+ const splitMillion = priceText.match(/(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/iu);
190
+ if (splitMillion) {
191
+ const millions = Number(splitMillion[1]);
192
+ const thousands = Number(splitMillion[2]);
193
+ const amount = millions * 1_000_000 + thousands * 1_000;
194
+ if (amount >= 1_000_000 && amount <= 5_000_000_000) price = amount;
195
+ }
196
+ }
197
+
198
+ const labelled = priceText.match(new RegExp(
199
+ `${PRICE_KEYWORD}\\s*[:\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})(?:\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`,
200
+ 'iu',
201
+ ));
95
202
  if (price == null && labelled) {
96
- const amount = parseNumericAmount(labelled[1]);
97
- if (amount != null && amount >= 50 && amount <= 5_000_000_000) price = amount;
203
+ const baseAmount = parseNumericAmount(labelled[1]);
204
+ // A common malformed Uzbek marketplace form is "450000 ming som". The
205
+ // already-expanded amount is the intended 450,000; multiplying it by
206
+ // another thousand would create a 450,000,000 false price. Keep normal
207
+ // shorthand such as "450 ming" and "2500 ming" scaled.
208
+ const ignoreRepeatedUzbekThousand = labelled[2]
209
+ && /^(?:ming|минг)$/iu.test(labelled[2])
210
+ && baseAmount != null
211
+ && baseAmount >= 10_000;
212
+ const amount = labelled[2] && !ignoreRepeatedUzbekThousand
213
+ ? parseScaledAmount(labelled[1], labelled[2])
214
+ : baseAmount;
215
+ if (amount != null && amount >= 50 && amount <= 5_000_000_000) price = Math.round(amount);
98
216
  }
99
217
 
100
218
  if (price == null) {
@@ -109,8 +227,8 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
109
227
  const reSymNum = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
110
228
  for (const regex of [reNumSym, reSymNum]) {
111
229
  let match;
112
- while ((match = regex.exec(text)) !== null) {
113
- if (isPaymentScopedAmount(text, match.index ?? 0, regex.lastIndex)) continue;
230
+ while ((match = regex.exec(priceText)) !== null) {
231
+ if (isPaymentScopedAmount(priceText, match.index ?? 0, regex.lastIndex)) continue;
114
232
  const amount = parseNumericAmount(match[1]);
115
233
  if (amount != null && amount >= 50 && amount <= 5_000_000_000 && (tagged == null || amount > tagged)) tagged = amount;
116
234
  }
@@ -118,27 +236,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
118
236
  price = tagged;
119
237
  }
120
238
 
121
- // Uzbek classifieds often split a round million and the trailing thousands:
122
- // "2 млн 500" / "2 миллион 500" means 2,500,000, not 2,000,000 plus an unrelated 500.
123
- if (price == null) {
124
- const splitMillion = text.match(/(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/iu);
125
- if (splitMillion) {
126
- const millions = Number(splitMillion[1]);
127
- const thousands = Number(splitMillion[2]);
128
- const amount = millions * 1_000_000 + thousands * 1_000;
129
- if (amount >= 1_000_000 && amount <= 5_000_000_000) price = amount;
130
- }
131
- }
132
-
133
239
  if (price == null) {
134
- const scalePattern = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
135
- .sort((a, b) => String(b).length - String(a).length)
136
- .map(escapeRegex)
137
- .join('|');
138
240
  // Multiplier aliases include useful one-letter forms such as `m` and `k`.
139
241
  // Require the alias to end at a token boundary so measurements/words like
140
242
  // `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
141
- const match = text.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${scalePattern})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
243
+ const match = priceText.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
142
244
  if (match) {
143
245
  const amount = parseScaledAmount(match[1], match[2]);
144
246
  if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
@@ -148,10 +250,10 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
148
250
  if (price == null) {
149
251
  const bareAmountRe = /\d{1,3}(?:[ \u00A0.,]\d{3})+|\d{4,}/g;
150
252
  let best = null;
151
- for (const match of text.matchAll(bareAmountRe)) {
253
+ for (const match of priceText.matchAll(bareAmountRe)) {
152
254
  const raw = match[0];
153
255
  const start = match.index ?? 0;
154
- if (isPaymentScopedAmount(text, start, start + raw.length)) continue;
256
+ if (isPaymentScopedAmount(priceText, start, start + raw.length)) continue;
155
257
  const digits = raw.replace(/[\s.,]/g, '');
156
258
  if (digits[0] === '0') continue;
157
259
  const amount = parseNumericAmount(raw);
@@ -161,11 +263,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
161
263
  }
162
264
 
163
265
  if (!explicit && fallbackCurrency === 'UZS' && price != null) {
164
- const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(text);
266
+ const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(priceText);
165
267
  currency = price >= 1_000_000 || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
166
268
  }
167
269
 
168
- return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(text) });
270
+ return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(priceText) });
169
271
  }
170
272
 
171
273
  // Compatibility name for callers migrating from Flat Finder's local parser.
@@ -102,13 +102,17 @@ export function parseHousingFloor(value) {
102
102
  }
103
103
  }
104
104
 
105
- const explicit = text.match(/(?:этаж|поверх|floor|etaj|qavat|қабат|кават|қават)[^\S\r\n]*[:№#-]?[^\S\r\n]*(\d{1,3})[^\S\r\n]*(?:[,;/]|из|of|din|dan)?[^\S\r\n]*(?:дом[^\S\r\n]*)?(?:из[^\S\r\n]*)?(\d{1,3})?[^\S\r\n]*(?:этаж\p{L}*|поверх\p{L}*|floors?|etaje|qavatli|қабатты|каватли|қаватли)?/iu);
106
- let floor = toNumber(explicit?.[1]);
107
- let totalFloors = toNumber(explicit?.[2]);
105
+ // Prefer the common "7 этаж" form before trying "этаж 7". Otherwise the
106
+ // marker-first parser can consume the next unrelated number (for example
107
+ // "7 этаж 44м²") and report floor 44.
108
+ const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
109
+ let floor = toNumber(beforeMarker?.[1]);
110
+ let totalFloors = null;
108
111
 
109
112
  if (floor == null) {
110
- const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
111
- floor = toNumber(beforeMarker?.[1]);
113
+ const explicit = text.match(/(?:этаж|поверх|floor|etaj|qavat|қабат|кават|қават)[^\S\r\n]*[:№#-]?[^\S\r\n]*(\d{1,3})[^\S\r\n]*(?:[,;/]|из|of|din|dan)?[^\S\r\n]*(?:дом[^\S\r\n]*)?(?:из[^\S\r\n]*)?(\d{1,3})?[^\S\r\n]*(?:этаж\p{L}*|поверх\p{L}*|floors?|etaje|qavatli|қабатты|каватли|қаватли)?/iu);
114
+ floor = toNumber(explicit?.[1]);
115
+ totalFloors = toNumber(explicit?.[2]);
112
116
  }
113
117
 
114
118
  if (floor == null && FIRST_FLOOR_WORD_RE.test(text)) floor = 1;