@whiteslove/parsing-lexicon 0.6.16 → 0.6.17

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.6.16",
3
+ "version": "0.6.17",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,19 @@ export type HousingPriceParseResult = Readonly<{
4
4
  approximate: boolean;
5
5
  }>;
6
6
 
7
- export function parseHousingPrice(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
8
- export function parseHousingPricePerSqm(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
9
- export const parsePriceFromText: typeof parseHousingPrice;
7
+ export type HousingMoneyParseContext = Readonly<{
8
+ country?: string;
9
+ currency?: string;
10
+ fallbackCurrency?: string;
11
+ dealType?: 'sale' | 'longRent' | 'shortRent' | string | null;
12
+ }>;
13
+
14
+ export function parseHousingPrice(
15
+ value: unknown,
16
+ context?: string | HousingMoneyParseContext,
17
+ ): HousingPriceParseResult;
18
+ export function parseHousingPricePerSqm(
19
+ value: unknown,
20
+ context?: string | HousingMoneyParseContext,
21
+ ): HousingPriceParseResult;
22
+ export const parsePriceFromText: typeof parseHousingPrice;
@@ -9,6 +9,11 @@ import {
9
9
  } from './money-core.js';
10
10
  import { maskPhoneLikeSpans } from './contact.js';
11
11
  import { DEPOSIT_TERMS, SELLER_TERMS } from './housing.js';
12
+ import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
13
+ import {
14
+ classifyHousingSingleMSpans,
15
+ HOUSING_NUMERIC_SPAN_TYPES,
16
+ } from './housing-numeric-spans.js';
12
17
 
13
18
  const PRICE_KEYWORD = '(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\\s+плата)?|rent)';
14
19
  // moneyCurrencyPattern() includes short codes (cad, ron, aed...) with no
@@ -30,7 +35,12 @@ function escapeRegex(value) {
30
35
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
36
  }
32
37
 
38
+ // One-letter `m/м` is deliberately excluded from the generic multiplier set.
39
+ // Its meaning is resolved by housing-numeric-spans.js first, so area/distance/
40
+ // microdistrict notation cannot leak into money parsing while explicit price
41
+ // contexts can still use compact million shorthand.
33
42
  const SCALE_PATTERN = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
43
+ .filter((alias) => !/^[mм]$/iu.test(String(alias)))
34
44
  .sort((a, b) => String(b).length - String(a).length)
35
45
  .map(escapeRegex)
36
46
  .join('|');
@@ -51,6 +61,32 @@ const PER_SQM_BEFORE_AMOUNT_RE = new RegExp(
51
61
  'igu',
52
62
  );
53
63
 
64
+ const COMMON_HOUSING_STRUCTURE_PATTERNS = Object.freeze([
65
+ /(?:^|[^\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,
66
+ ]);
67
+
68
+ function moneyParsingContext(value = '') {
69
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
70
+ const country = canonicalCountryCode(value.country) || '';
71
+ const currency = String(value.currency || value.fallbackCurrency || countryByCode(country)?.currency || '')
72
+ .trim()
73
+ .toUpperCase();
74
+ const rawDealType = String(value.dealType || '').trim();
75
+ const dealType = ['sale', 'longRent', 'shortRent'].includes(rawDealType) ? rawDealType : null;
76
+ return { country, currency, dealType };
77
+ }
78
+
79
+ const currency = String(value || '').trim().toUpperCase();
80
+ const countryMatches = currency
81
+ ? COUNTRIES.filter((item) => item.currency === currency)
82
+ : [];
83
+ return {
84
+ country: countryMatches.length === 1 ? countryMatches[0].code : '',
85
+ currency,
86
+ dealType: null,
87
+ };
88
+ }
89
+
54
90
  function parsedMoneyAmount(numberValue, scaleValue) {
55
91
  const amount = scaleValue
56
92
  ? parseScaledAmount(numberValue, scaleValue)
@@ -99,6 +135,22 @@ function maskRanges(text, ranges) {
99
135
  return masked;
100
136
  }
101
137
 
138
+ function maskPatternMatches(text, patterns) {
139
+ let masked = text;
140
+ for (const pattern of patterns) {
141
+ pattern.lastIndex = 0;
142
+ masked = masked.replace(pattern, (match) => ' '.repeat(match.length));
143
+ }
144
+ return masked;
145
+ }
146
+
147
+ function maskHousingStructuralSpans(text, context) {
148
+ const strictMasked = maskPatternMatches(text, COMMON_HOUSING_STRUCTURE_PATTERNS);
149
+ const nonMoneySpans = classifyHousingSingleMSpans(strictMasked, context)
150
+ .filter((span) => span.type !== HOUSING_NUMERIC_SPAN_TYPES.MONEY);
151
+ return maskRanges(strictMasked, nonMoneySpans);
152
+ }
153
+
102
154
  function isPaymentScopedAmount(text, start, end) {
103
155
  const left = text
104
156
  .slice(Math.max(0, start - 56), start)
@@ -108,16 +160,31 @@ function isPaymentScopedAmount(text, start, end) {
108
160
  .slice(end, Math.min(text.length, end + 28))
109
161
  .split(/[\r\n.;!?]/u)[0] || '';
110
162
 
111
- // "deposit 500$" / "commission 100$" are payment details, not the
112
- // listing price. For the reverse form ("500$ deposit"), only suppress the
113
- // amount when there is no explicit price/rent label immediately to its left;
114
- // this preserves text such as "rent 800$, deposit 500$".
163
+ // Prefix payment labels bind to the amount on their right: "deposit 500$".
115
164
  if (findCanonical(left, PAYMENT_AMOUNT_TERMS, { partial: true })) return true;
165
+
166
+ // A suffix label binds only when it appears before another numeric token.
167
+ // This prevents the listing price in "6000грн+комуналка+6000(залог)" from
168
+ // being suppressed merely because the later deposit keyword is nearby, while
169
+ // still excluding direct suffix forms such as "500$ deposit".
170
+ const nextDigit = right.search(/\d/u);
171
+ const suffixScope = nextDigit === -1 ? right : right.slice(0, nextDigit);
116
172
  return !PRICE_KEYWORD_RE.test(left)
117
- && Boolean(findCanonical(right, PAYMENT_AMOUNT_TERMS, { partial: true }));
173
+ && Boolean(findCanonical(suffixScope, PAYMENT_AMOUNT_TERMS, { partial: true }));
174
+ }
175
+
176
+ function parseContextualSingleLetterMillion(text, context) {
177
+ for (const span of classifyHousingSingleMSpans(text, context)) {
178
+ if (span.type !== HOUSING_NUMERIC_SPAN_TYPES.MONEY) continue;
179
+ if (isPaymentScopedAmount(text, span.start, span.end)) continue;
180
+ const amount = span.amount * 1_000_000;
181
+ if (amount >= 1_000_000 && amount <= 5_000_000_000) return Math.round(amount);
182
+ }
183
+ return null;
118
184
  }
119
185
 
120
- export function parseHousingPricePerSqm(value, fallbackCurrency = '') {
186
+ export function parseHousingPricePerSqm(value, context = '') {
187
+ const { currency: fallbackCurrency } = moneyParsingContext(context);
121
188
  const original = String(value || '');
122
189
  if (!original) return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
123
190
 
@@ -135,18 +202,21 @@ export function parseHousingPricePerSqm(value, fallbackCurrency = '') {
135
202
  });
136
203
  }
137
204
 
138
- export function parseHousingPrice(value, fallbackCurrency = '') {
205
+ export function parseHousingPrice(value, context = '') {
206
+ const { country, currency: fallbackCurrency, dealType } = moneyParsingContext(context);
139
207
  const original = String(value || '');
140
208
  if (!original) return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
141
209
 
142
210
  // Contact spans are removed once, before every money branch. A phone can
143
211
  // therefore never win as a labelled, currency-tagged or fallback amount.
144
212
  const text = maskPhoneLikeSpans(original);
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));
213
+ // Unit prices are not listing totals. Mask their exact spans first. Then the
214
+ // shared numeric-span classifier masks all N m/Nм spans that resolve to area,
215
+ // distance, microdistrict or unknown; only positively classified money spans
216
+ // remain visible to the price parser.
217
+ const withoutUnitPrices = maskRanges(text, perSquareMeterMatches(text, fallbackCurrency));
218
+ const numericContext = { country, dealType };
219
+ const priceText = maskHousingStructuralSpans(withoutUnitPrices, numericContext);
150
220
  let currency = moneyCurrencyFromText(priceText, fallbackCurrency || '')
151
221
  || moneyCurrencyFromText(text, fallbackCurrency || '')
152
222
  || '';
@@ -195,6 +265,17 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
195
265
  }
196
266
  }
197
267
 
268
+ // Single-letter million shorthand is accepted only when the numeric-span
269
+ // classifier resolves the span as money. This supports explicit "price 2m"
270
+ // / "2m USD" everywhere and bare compact "800m" only for Uzbek sale context.
271
+ if (price == null) {
272
+ const contextualMillion = parseContextualSingleLetterMillion(priceText, numericContext);
273
+ if (contextualMillion != null) {
274
+ price = contextualMillion;
275
+ currency = moneyCurrencyFromText(priceText, fallbackCurrency || '') || fallbackCurrency || '';
276
+ }
277
+ }
278
+
198
279
  const labelled = priceText.match(new RegExp(
199
280
  `${PRICE_KEYWORD}\\s*[:\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})(?:\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`,
200
281
  'iu',
@@ -237,22 +318,10 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
237
318
  }
238
319
 
239
320
  if (price == null) {
240
- // Multiplier aliases include useful one-letter forms such as `m` and `k`.
241
- // Require the alias to end at a token boundary so measurements/words like
242
- // `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
243
321
  const match = priceText.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
244
322
  if (match) {
245
- // Ukrainian listings abbreviate a microdistrict as “531 м/р”. The
246
- // one-letter million scale is otherwise valid, but this trailing
247
- // “/р” makes the token an area identifier, never a price.
248
- const tail = priceText.slice((match.index ?? 0) + match[0].length);
249
- if (/^\s*\/\s*[рr](?=$|[^\p{L}\p{N}_])/iu.test(tail)) {
250
- // Fall through to the ordinary bare-number pass, which can still
251
- // recover a genuine rent amount later in the listing.
252
- } else {
253
323
  const amount = parseScaledAmount(match[1], match[2]);
254
324
  if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
255
- }
256
325
  }
257
326
  }
258
327
 
@@ -0,0 +1,93 @@
1
+ import { canonicalCountryCode } from './countries.js';
2
+ import { moneyCurrencyPattern, parseNumericAmount } from './money-core.js';
3
+
4
+ export const HOUSING_NUMERIC_SPAN_TYPES = Object.freeze({
5
+ MONEY: 'money',
6
+ AREA: 'area',
7
+ DISTANCE: 'distance',
8
+ MICRODISTRICT: 'microdistrict',
9
+ UNKNOWN: 'unknown',
10
+ });
11
+
12
+ const PRICE_LABEL_BEFORE_RE = /(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\s+плата)?|rent)[^\r\n]{0,16}$/iu;
13
+ const CURRENCY_AFTER_RE = new RegExp(
14
+ `^\\s*(?:uzbek(?:istan)?\\s+)?(?:${moneyCurrencyPattern()})(?![\\p{L}\\p{N}_])`,
15
+ 'iu',
16
+ );
17
+ const UZ_SALE_SIGNAL_RE = /(?:sotiladi|sotuv(?:da)?|sotaman|прода[её]тся|продам|продаж[аеи]|for\s+sale|sale)/iu;
18
+ const UNICODE_TOKEN_END = String.raw`(?![\p{L}\p{N}_])`;
19
+
20
+ const MICRODISTRICT_AFTER_RE = /^\s*\/\s*[rр](?=$|[^\p{L}\p{N}_])/iu;
21
+ const AREA_BEFORE_RE = /(?:общ(?:ая)?\.?\s*пл(?:ощад[ьи])?\.?|загальн\p{L}*\s+площ\p{L}*|площа|площадь|umumiy\s+maydon|maydon|area|surface|suprafa(?:ță|ta))[^\r\n,;]{0,12}$/iu;
22
+ const AREA_AFTER_RE = new RegExp(
23
+ `^[\\s,;:()\\-–—]*(?:общ(?:ая)?\\.?\\s*пл(?:ощад[ьи])?\\.?|загальн\\p{L}*\\s+площ\\p{L}*|площа|площадь|kv\\.?\\s*m|sqm|sq\\.?\\s*m|umumiy\\s+maydon|maydon|area|surface|suprafa(?:ță|ta))${UNICODE_TOKEN_END}`,
24
+ 'iu',
25
+ );
26
+
27
+ const DISTANCE_OBJECT = String.raw`(?:metro(?:ga|dan|gacha)?|метро|maktab(?:ga|dan|gacha)?|school|школ\p{L}*|bozor(?:ga|dan|gacha)?|market|рынок\p{L}*|bekat(?:ga|dan|gacha)?|stop|station|остановк\p{L}*|park(?:ga|dan|gacha)?|парк\p{L}*|aeroport(?:ga|dan|gacha)?|airport|аэропорт\p{L}*|do['’ʻʼ]?kon(?:ga|dan|gacha)?|магазин\p{L}*)`;
28
+ const DISTANCE_BEFORE_RE = new RegExp(
29
+ `(?:${DISTANCE_OBJECT}|masofa|distance|(?:до|от|to|from)\\s+${DISTANCE_OBJECT})[^\\r\\n,;]{0,14}$`,
30
+ 'iu',
31
+ );
32
+ const DISTANCE_AFTER_RE = new RegExp(
33
+ `^[\\s,;:()\\-–—]*(?:(?:gacha|гача|masofa|distance|metr\\p{L}*|метр\\p{L}*)${UNICODE_TOKEN_END}|(?:до|от|to|from)\\s+${DISTANCE_OBJECT}${UNICODE_TOKEN_END}|${DISTANCE_OBJECT}${UNICODE_TOKEN_END}|(?:walk(?:ing)?|piyoda)\\s+(?:to\\s+)?${DISTANCE_OBJECT}${UNICODE_TOKEN_END})`,
34
+ 'iu',
35
+ );
36
+
37
+ const SINGLE_M_CANDIDATE_RE = /(?<![\p{L}\p{N}_])(\d{1,4}(?:[.,]\d{1,2})?)([ \u00A0]*)([mм])(?![\p{L}\p{N}_])/giu;
38
+
39
+ function normalizedDealType(value) {
40
+ const dealType = String(value || '').trim();
41
+ return ['sale', 'longRent', 'shortRent'].includes(dealType) ? dealType : null;
42
+ }
43
+
44
+ function classifySingleMSpan(text, match, { country, dealType }) {
45
+ const start = match.index ?? 0;
46
+ const end = SINGLE_M_CANDIDATE_RE.lastIndex;
47
+ const before = text.slice(Math.max(0, start - 64), start);
48
+ const after = text.slice(end, Math.min(text.length, end + 64));
49
+
50
+ if (MICRODISTRICT_AFTER_RE.test(after)) return HOUSING_NUMERIC_SPAN_TYPES.MICRODISTRICT;
51
+ if (AREA_BEFORE_RE.test(before) || AREA_AFTER_RE.test(after)) return HOUSING_NUMERIC_SPAN_TYPES.AREA;
52
+ if (DISTANCE_BEFORE_RE.test(before) || DISTANCE_AFTER_RE.test(after)) return HOUSING_NUMERIC_SPAN_TYPES.DISTANCE;
53
+
54
+ const explicitPrice = PRICE_LABEL_BEFORE_RE.test(before);
55
+ const explicitCurrency = CURRENCY_AFTER_RE.test(after);
56
+ if (explicitPrice || explicitCurrency) return HOUSING_NUMERIC_SPAN_TYPES.MONEY;
57
+
58
+ const compact = match[2].length === 0;
59
+ const localSaleSignal = country === 'UZ' && UZ_SALE_SIGNAL_RE.test(`${before} ${after}`);
60
+ if (country === 'UZ' && compact && (dealType === 'sale' || localSaleSignal)) {
61
+ return HOUSING_NUMERIC_SPAN_TYPES.MONEY;
62
+ }
63
+
64
+ return HOUSING_NUMERIC_SPAN_TYPES.UNKNOWN;
65
+ }
66
+
67
+ export function classifyHousingSingleMSpans(value, options = {}) {
68
+ const text = String(value || '');
69
+ if (!text) return Object.freeze([]);
70
+
71
+ const country = canonicalCountryCode(options.country) || String(options.country || '').trim().toUpperCase();
72
+ const dealType = normalizedDealType(options.dealType);
73
+ const spans = [];
74
+
75
+ SINGLE_M_CANDIDATE_RE.lastIndex = 0;
76
+ let match;
77
+ while ((match = SINGLE_M_CANDIDATE_RE.exec(text)) !== null) {
78
+ const amount = parseNumericAmount(match[1]);
79
+ if (amount == null) continue;
80
+ const start = match.index ?? 0;
81
+ const end = SINGLE_M_CANDIDATE_RE.lastIndex;
82
+ spans.push(Object.freeze({
83
+ start,
84
+ end,
85
+ raw: match[0],
86
+ amount,
87
+ compact: match[2].length === 0,
88
+ type: classifySingleMSpan(text, match, { country, dealType }),
89
+ }));
90
+ }
91
+
92
+ return Object.freeze(spans);
93
+ }
@@ -65,6 +65,7 @@ export type HousingStructuredOptions = Readonly<{
65
65
  country?: string;
66
66
  fallbackCurrency?: string;
67
67
  phoneCountry?: string;
68
+ dealType?: 'sale' | 'longRent' | 'shortRent' | string | null;
68
69
  source?: string;
69
70
  knownStreet?: string | null;
70
71
  allowBareAddress?: boolean;
@@ -92,7 +93,10 @@ export type HousingStructuredResult = Readonly<{
92
93
 
93
94
  export function parseHousingRoomCount(value: unknown): number | null;
94
95
  export function parseHousingFloor(value: unknown): Readonly<{ floor: number | null; totalFloors: number | null }>;
95
- export function parseHousingAreas(value: unknown): HousingAreaDetails;
96
+ export function parseHousingAreas(
97
+ value: unknown,
98
+ options?: Pick<HousingStructuredOptions, 'country' | 'dealType'>,
99
+ ): HousingAreaDetails;
96
100
  export function parseHousingPayments(value: unknown): HousingPaymentContext;
97
101
  export function parseHousingSeller(value: unknown): Readonly<{ type: 'owner' | 'agency' | null; confidence: number }>;
98
102
  export function parseHousingInfrastructure(value: unknown): readonly HousingInfrastructureMatch[];
@@ -13,6 +13,10 @@ import { parseHousingListingFields } from './housing-listing-fields.js';
13
13
  import { parseHousingPrice } from './housing-money.js';
14
14
  import { parseHousingAmenities, parseHousingResidentialComplex } from './housing-text.js';
15
15
  import { parseHousingSourcePost } from './housing-source-aliases.js';
16
+ import {
17
+ classifyHousingSingleMSpans,
18
+ HOUSING_NUMERIC_SPAN_TYPES,
19
+ } from './housing-numeric-spans.js';
16
20
 
17
21
  const NUMBER_WORDS = Object.freeze([
18
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],
@@ -63,11 +67,6 @@ function parseCompactSpec(text) {
63
67
  }
64
68
 
65
69
  function currencyNear(text) {
66
- // findCanonical's partial match requires the alias to be its own
67
- // space-delimited token, so a currency symbol glued directly to its
68
- // number with no space ("500.$", "350$") never matches. Fall back to
69
- // moneyCurrencyFromText, which detects symbols and codes independent of
70
- // surrounding whitespace (used by parseHousingPrice for the same reason).
71
70
  return findCanonical(text, CURRENCY_TERMS, { partial: true })?.canonical
72
71
  || moneyCurrencyFromText(text) || null;
73
72
  }
@@ -76,11 +75,6 @@ export function parseHousingRoomCount(value) {
76
75
  const text = normalizeUnicode(value ?? '');
77
76
  if (!text) return null;
78
77
 
79
- // Uzbek market shorthand: a unit formally registered as N rooms that was
80
- // physically converted into a different count ("1 xonali ... 2 xona
81
- // qilingan" — nominally 1-room, made into 2). The converted count is the
82
- // one that matters and must win over the nominal count mentioned earlier
83
- // in the same text.
84
78
  const converted = text.match(/(\d{1,2})\s*(?:ta\s*)?xona(?:ga)?\s+(?:qilingan|aylantirilgan|bo['’ʻʼ‘`]?lingan)(?=$|[^\p{L}\p{N}_])/iu);
85
79
  if (converted) {
86
80
  const rooms = toNumber(converted[1]);
@@ -99,14 +93,10 @@ export function parseHousingRoomCount(value) {
99
93
  if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
100
94
  }
101
95
 
102
- // Structured "Label - Value" reposts (Telegram channel copies of OLX ads)
103
- // put the label before the number: "Комнат - 4" instead of "4 комнаты".
104
96
  const reversed = text.match(/(?:комнат\p{L}*|xona(?:lar)?(?:i)?|хона(?:лар)?(?:и|лик|ли)?|rooms?)\s*[-:–—]\s*(\d{1,2})(?=$|[^\p{L}\p{N}])/iu);
105
97
  const reversedRooms = toNumber(reversed?.[1]);
106
98
  if (reversedRooms != null && reversedRooms >= 1 && reversedRooms <= 20) return reversedRooms;
107
99
 
108
- // Listings that only carry the compact "1/5/12-50m" shorthand state the room
109
- // count in its first number.
110
100
  return parseCompactSpec(text)?.rooms ?? null;
111
101
  }
112
102
 
@@ -114,17 +104,11 @@ export function parseHousingFloor(value) {
114
104
  const text = normalizeUnicode(value ?? '');
115
105
  if (!text) return deepFreeze({ floor: null, totalFloors: null });
116
106
 
117
- // The rooms/floor/total shorthand must be read before the generic
118
- // floor/total fraction parser or its first two numbers would incorrectly
119
- // become floor=2, totalFloors=10 for "2/10/16".
120
107
  const compactSpec = parseCompactSpec(text);
121
108
  if (compactSpec) {
122
109
  return deepFreeze({ floor: compactSpec.floor, totalFloors: compactSpec.totalFloors });
123
110
  }
124
111
 
125
- // Skip a pair that is merely the head of a rooms/floor/total triple the
126
- // shorthand parser above rejected: there the floor and the total floors are
127
- // the second and third numbers, never the first two.
128
112
  const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*[\/\\]\s*(\d{1,3})(?!\s*[\/\\]\s*\d)(?=$|[^\d])/u);
129
113
  if (fraction) {
130
114
  const floor = toNumber(fraction[1]);
@@ -143,9 +127,6 @@ export function parseHousingFloor(value) {
143
127
  }
144
128
  }
145
129
 
146
- // Prefer the common "7 этаж" form before trying "этаж 7". Otherwise the
147
- // marker-first parser can consume the next unrelated number (for example
148
- // "7 этаж 44м²") and report floor 44.
149
130
  const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
150
131
  let floor = toNumber(beforeMarker?.[1]);
151
132
  let totalFloors = null;
@@ -164,7 +145,6 @@ export function parseHousingFloor(value) {
164
145
  }
165
146
 
166
147
  if (totalFloors == null) {
167
- // Structured "Label - Value" reposts: "Этажность - 5" instead of "5 этажей".
168
148
  const reversedTotal = text.match(/(?:этажность|поверховість|qavatlilik|қабаттылық)\s*[-:–—]\s*(\d{1,3})(?=$|[^\p{L}\p{N}])/iu);
169
149
  totalFloors = toNumber(reversedTotal?.[1]);
170
150
  }
@@ -180,17 +160,18 @@ const AREA_LABELS = Object.freeze([
180
160
  ['kitchen', /(?:площадь\s+кухни|кухня|kitchen(?:\s+area)?|bucătărie|bucatarie|oshxona|асүй)/iu],
181
161
  ['balcony', /(?:балкон|лоджия|balcony|loggia|balcon|balkon)/iu],
182
162
  ['terrace', /(?:терраса|terrace|teras(?:ă|a)|terrasa)/iu],
183
- ['total', /(?:общая\s+площадь|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
163
+ ['total', /(?:общ(?:ая)?\.?\s*пл(?:ощад[ьи])?\.?|общая\s+площадь|загальн\p{L}*\s+площ\p{L}*|площа|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
184
164
  ]);
185
165
 
186
166
  const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s*м|кв2|квадрат\p{L}*)`;
167
+ const AREA_UNIT_AFTER_LABEL_RE = String.raw`(?:${AREA_UNIT_RE}|[mм])`;
187
168
 
188
169
  function areaAfterLabel(text, labelRe) {
189
- const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_RE}`, 'iu');
170
+ const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_AFTER_LABEL_RE}(?=$|[^\\p{L}\\p{N}_])`, 'iu');
190
171
  return toNumber(text.match(re)?.[1]);
191
172
  }
192
173
 
193
- export function parseHousingAreas(value) {
174
+ export function parseHousingAreas(value, options = {}) {
194
175
  const text = normalizeUnicode(value ?? '');
195
176
  const result = { total: null, living: null, kitchen: null, balcony: null, terrace: null };
196
177
  if (!text) return deepFreeze(result);
@@ -200,8 +181,11 @@ export function parseHousingAreas(value) {
200
181
  const generic = text.match(new RegExp(`(?:^|[^\\d])(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_RE}(?=$|[^\\p{L}\\p{N}])`, 'iu'));
201
182
  result.total = toNumber(generic?.[1]);
202
183
  }
203
- // The compact "1/5/12-50m" shorthand glues the area to the floor block, where
204
- // the unit is often a bare "m" the generic area matcher cannot trust alone.
184
+ if (result.total == null) {
185
+ const typedArea = classifyHousingSingleMSpans(text, options)
186
+ .find((span) => span.type === HOUSING_NUMERIC_SPAN_TYPES.AREA);
187
+ result.total = typedArea?.amount ?? null;
188
+ }
205
189
  if (result.total == null) result.total = parseCompactSpec(text)?.areaSqm ?? null;
206
190
  for (const key of Object.keys(result)) {
207
191
  const number = result[key];
@@ -319,7 +303,7 @@ function distanceFromWindow(window, entityOffset = 0) {
319
303
  : /(?:на\s+машине|by\s+car|drive|mashinada|көлікпен)/iu.test(local) ? 'drive' : null;
320
304
  candidates.push({ distance: Math.abs(index - entityOffset), value: Number(match[1]), unit: 'minute', mode });
321
305
  }
322
- const metricRe = /(\d{1,4}(?:[.,]\d+)?)\s*(км|km|километр\p{L}*|м|meter(?:s)?|метр\p{L}*)/giu;
306
+ const metricRe = /(\d{1,4}(?:[.,]\d+)?)\s*(км|km|километр\p{L}*|м|m(?![\p{L}\p{N}_])|meter(?:s)?|метр\p{L}*)/giu;
323
307
  for (const match of window.matchAll(metricRe)) {
324
308
  const index = match.index ?? 0;
325
309
  const rawUnit = match[2].toLocaleLowerCase();
@@ -431,11 +415,16 @@ export function parseHousingStructured(value, options = {}) {
431
415
  const fallbackCurrency = options.fallbackCurrency || countryCurrency(country) || '';
432
416
  const phoneCountry = options.phoneCountry || countryPhoneHint(country) || null;
433
417
  const intent = resolveHousingIntent(text);
434
- const listingFields = parseHousingListingFields(text, { country, dealType: intent?.dealType || null });
418
+ const sourceDealType = ['sale', 'longRent', 'shortRent'].includes(String(options.dealType || ''))
419
+ ? String(options.dealType)
420
+ : null;
421
+ const effectiveDealType = intent?.dealType || sourceDealType || null;
422
+ const listingFields = parseHousingListingFields(text, { country, dealType: effectiveDealType });
435
423
  const parsedPayments = parseHousingPaymentDetails(text);
436
- const payments = intent?.dealType === 'sale'
424
+ const payments = effectiveDealType === 'sale'
437
425
  ? deepFreeze({ ...parsedPayments, utilities: null })
438
426
  : parsedPayments;
427
+ const numericOptions = { country, dealType: effectiveDealType };
439
428
 
440
429
  return deepFreeze({
441
430
  text,
@@ -447,8 +436,12 @@ export function parseHousingStructured(value, options = {}) {
447
436
  context: parseHousingContext(text),
448
437
  rooms: parseHousingRoomCount(text),
449
438
  floor: parseHousingFloor(text),
450
- area: parseHousingAreas(text),
451
- price: parseHousingPrice(text, fallbackCurrency),
439
+ area: parseHousingAreas(text, numericOptions),
440
+ price: parseHousingPrice(text, {
441
+ country,
442
+ currency: fallbackCurrency,
443
+ dealType: effectiveDealType,
444
+ }),
452
445
  address: parseHousingAddress(text, {
453
446
  knownStreet: options.knownStreet || null,
454
447
  allowDelimitedBare: options.allowDelimitedBareAddress === true,