@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 +1 -1
- package/src/housing-money.d.ts +16 -3
- package/src/housing-money.js +93 -24
- package/src/housing-numeric-spans.js +93 -0
- package/src/housing-structured.d.ts +5 -1
- package/src/housing-structured.js +27 -34
package/package.json
CHANGED
package/src/housing-money.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ export type HousingPriceParseResult = Readonly<{
|
|
|
4
4
|
approximate: boolean;
|
|
5
5
|
}>;
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
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;
|
package/src/housing-money.js
CHANGED
|
@@ -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
|
-
//
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
const
|
|
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(
|
|
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', /(
|
|
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*${
|
|
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
|
-
|
|
204
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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,
|