@whiteslove/parsing-lexicon 0.6.15 → 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-address.js +6 -0
- package/src/housing-card-fields.js +1 -1
- package/src/housing-listing-enrichment.d.ts +4 -1
- package/src/housing-listing-enrichment.js +6 -4
- package/src/housing-listing-fields.d.ts +1 -1
- package/src/housing-listing-fields.js +17 -4
- package/src/housing-money.d.ts +16 -3
- package/src/housing-money.js +93 -15
- package/src/housing-numeric-spans.js +93 -0
- package/src/housing-structured.d.ts +5 -1
- package/src/housing-structured.js +34 -35
- package/src/housing-text.js +2 -1
- package/src/ua-kharkiv-location-translations.js +2 -0
- package/src/ua-kharkiv-microdistrict-extensions.js +1 -0
package/package.json
CHANGED
package/src/housing-address.js
CHANGED
|
@@ -39,6 +39,12 @@ function compactStreet(value) {
|
|
|
39
39
|
// "проживания" -> "оживания").
|
|
40
40
|
.replace(new RegExp(`^${PREFIX_STREET_MARKER}(?!\\p{L})\\s*`, 'iu'), '')
|
|
41
41
|
.replace(new RegExp(`\\s+${POSTFIX_STREET_MARKER}$`, 'iu'), '')
|
|
42
|
+
// OCR frequently substitutes “оя” for the Ukrainian/Russian “ля” in
|
|
43
|
+
// “шлях”; correct the street-token typo before canonical lookup.
|
|
44
|
+
.replace(/(?<!\p{L})шоях(?!\p{L})/giu, 'шлях')
|
|
45
|
+
// Listings commonly abbreviate this Kharkiv street to an initial and
|
|
46
|
+
// surname; expand it so address-only consumers do not retain just “Л”.
|
|
47
|
+
.replace(/(?<!\p{L})л\s*\.\s*малой(?!\p{L})/giu, 'Любови Малой')
|
|
42
48
|
.replace(/[\s,;:.\-–—]+$/gu, '')
|
|
43
49
|
.trim() || null;
|
|
44
50
|
}
|
|
@@ -51,7 +51,7 @@ function enumeratedNearby(text) {
|
|
|
51
51
|
const items = [];
|
|
52
52
|
for (const item of blocks.join(', ').split(/[,;•·|]+/).map((part) => part
|
|
53
53
|
.replace(/\s{2,}/g, ' ')
|
|
54
|
-
.replace(/^[\s\-–—.]+|[\s\-–—.!]+$/g, '')
|
|
54
|
+
.replace(/^[\s\-–—."'«»“”„]+|[\s\-–—.!"'«»“”„]+$/g, '')
|
|
55
55
|
.trim())) {
|
|
56
56
|
if (item.length < 3 || item.length > 45 || NEARBY_NOISE_RE.test(item) || NEARBY_FILLER_RE.test(item)) continue;
|
|
57
57
|
if (!/\p{L}{3,}/u.test(item)) continue;
|
|
@@ -71,4 +71,7 @@ export function parseHousingCommissionAmount(value: unknown): HousingCommissionA
|
|
|
71
71
|
export function parseHousingPerPersonPrice(value: unknown, options?: { country?: string }): HousingPerPersonPrice | null;
|
|
72
72
|
export function parseHousingTransitRoutes(value: unknown): readonly string[];
|
|
73
73
|
export function parseHousingObservedAmenities(value: unknown): readonly string[];
|
|
74
|
-
export function parseHousingListingEnrichment(
|
|
74
|
+
export function parseHousingListingEnrichment(
|
|
75
|
+
value: unknown,
|
|
76
|
+
options?: { country?: string; dealType?: 'sale' | 'longRent' | 'shortRent' | null },
|
|
77
|
+
): Readonly<HousingListingEnrichment>;
|
|
@@ -9,6 +9,7 @@ import { parseHousingListingFields } from './housing-listing-fields.js';
|
|
|
9
9
|
import { parseHousingResidentialComplex } from './housing-text.js';
|
|
10
10
|
import { parseHousingAddress } from './housing-address.js';
|
|
11
11
|
import { HOUSING_LANDMARK_EXTENSIONS, HOUSING_POI_EXTENSIONS } from './housing-poi-extensions.js';
|
|
12
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
12
13
|
|
|
13
14
|
const GENERIC_CATEGORY = Object.freeze({
|
|
14
15
|
Park: 'park', Metro: 'metro', 'Bus stop': 'transport', 'Public transport': 'transport', 'Main road': 'transport',
|
|
@@ -229,10 +230,11 @@ function walkMinutes(text) {
|
|
|
229
230
|
return Number.isInteger(value) && value > 0 && value <= 180 ? value : null;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
233
|
+
export function parseHousingListingEnrichment(value, { country = '', dealType = null } = {}) {
|
|
233
234
|
const text = normalizeUnicode(value ?? '');
|
|
234
235
|
if (!text) return deepFreeze({});
|
|
235
|
-
const
|
|
236
|
+
const resolvedDealType = dealType || resolveHousingIntent(text)?.dealType || null;
|
|
237
|
+
const listingFields = parseHousingListingFields(text, { country, dealType: resolvedDealType });
|
|
236
238
|
const floor = parseHousingFloor(text);
|
|
237
239
|
const areas = parseHousingAreas(text);
|
|
238
240
|
const audience = parseHousingAudience(text);
|
|
@@ -271,7 +273,7 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
271
273
|
euroLayout: listingFields.euroLayout ?? null,
|
|
272
274
|
gas: listingFields.gas ?? null,
|
|
273
275
|
newBuilding: listingFields.newBuilding ?? null,
|
|
274
|
-
communalSeparated: listingFields.communalSeparated ?? null,
|
|
276
|
+
communalSeparated: resolvedDealType === 'sale' ? null : (listingFields.communalSeparated ?? null),
|
|
275
277
|
parking: listingFields.parking ?? null,
|
|
276
278
|
elevator: listingFields.elevator ?? null,
|
|
277
279
|
heating: listingFields.heating ?? null,
|
|
@@ -284,7 +286,7 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
284
286
|
furnished: listingFields.furnished ?? null,
|
|
285
287
|
deposit: listingFields.depositRequired ?? null,
|
|
286
288
|
firstRental: firstRental(text, listingFields),
|
|
287
|
-
utilitiesAmount: listingFields.utilitiesAmount ?? null,
|
|
289
|
+
utilitiesAmount: resolvedDealType === 'sale' ? null : (listingFields.utilitiesAmount ?? null),
|
|
288
290
|
commission,
|
|
289
291
|
commissionPercent: commission === false ? 0 : null,
|
|
290
292
|
commissionAmount,
|
|
@@ -53,5 +53,5 @@ export type HousingListingFields = Readonly<{
|
|
|
53
53
|
*/
|
|
54
54
|
export function parseHousingListingFields(
|
|
55
55
|
value: unknown,
|
|
56
|
-
options?: { country?: string },
|
|
56
|
+
options?: { country?: string; dealType?: 'sale' | 'longRent' | 'shortRent' | null },
|
|
57
57
|
): Readonly<Partial<HousingListingFields>>;
|
|
@@ -2,6 +2,7 @@ import { deepFreeze } from './lexicon-core.js';
|
|
|
2
2
|
import { normalizeUnicode } from './normalization.js';
|
|
3
3
|
import { parseHousingContext } from './housing-context.js';
|
|
4
4
|
import { parseHousingFeatures } from './housing-features.js';
|
|
5
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
5
6
|
|
|
6
7
|
const bool = (text, positive, negative = null) => {
|
|
7
8
|
if (negative?.test(text)) return false;
|
|
@@ -69,6 +70,11 @@ function parseAvailableFrom(text) {
|
|
|
69
70
|
const match = text.match(/(?:доступн\p{L}*\s+с|свободн\p{L}*\s+с|заселени\p{L}*\s+с|заезд\s+с|available\s+from|move[- ]?in\s+from)\s*[:\-]?\s*((?:\d{1,2}[./-]\d{1,2}(?:[./-]\d{2,4})?)|(?:\d{1,2}\s+[\p{L}]{3,12}(?:\s+\d{4})?))/iu);
|
|
70
71
|
if (match?.[1]) return match[1].trim();
|
|
71
72
|
|
|
73
|
+
// Ukrainian listings commonly phrase this as “move-in is possible only
|
|
74
|
+
// from …”, rather than using the shorter “available from …” form.
|
|
75
|
+
const ukrainianMoveIn = text.match(/(?:перегляд\s+(?:і|та)\s+)?заселен\p{L}*\s+можлив\p{L}*(?:\s+лише)?\s+з\s*((?:\d{1,2}[./-]\d{1,2}(?:[./-]\d{2,4})?)|(?:\d{1,2}\s+[\p{L}]{3,12}(?:\s+\d{4})?))/iu);
|
|
76
|
+
if (ukrainianMoveIn?.[1]) return ukrainianMoveIn[1].trim();
|
|
77
|
+
|
|
72
78
|
// Uzbek listings commonly attach the ablative suffix directly to the month:
|
|
73
79
|
// "1-sentyabrdan beriladi" / "1-сентябрдан берилади".
|
|
74
80
|
const uzbek = text.match(/(\d{1,2}\s*[-./]?\s*[\p{L}]{3,12})(?:dan|дан)(?=$|[^\p{L}\p{N}_])[^.\r\n]{0,32}(?:beriladi|берилади|bo['’]?sh|бўш)/iu);
|
|
@@ -76,7 +82,9 @@ function parseAvailableFrom(text) {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
function parseUtilitiesAmount(text) {
|
|
79
|
-
|
|
85
|
+
// Do not bridge a sentence boundary. Otherwise “plus utilities. Tel. 06…”
|
|
86
|
+
// turns the first phone digits into a fictitious utility bill.
|
|
87
|
+
const match = text.match(/(?:коммунальн\p{L}*|коммуналк\p{L}*|ком\.?\s*услуг\p{L}*|utilities?|bills?)[^\d.;!?…\r\n]{0,24}(?:около|примерно|~|≈)?\s*(\d{2,8}(?:[.,]\d{1,2})?)\s*(₴|грн|uah|\$|usd|€|eur|сум|uzs|тг|kzt|lei|ron|руб|rub)?/iu);
|
|
80
88
|
if (!match) return null;
|
|
81
89
|
const amount = Number(match[1].replace(',', '.'));
|
|
82
90
|
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
@@ -103,11 +111,16 @@ function parseDepositRequired(text) {
|
|
|
103
111
|
return null;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
114
|
+
export function parseHousingListingFields(value, { country = '', dealType = null } = {}) {
|
|
107
115
|
const text = normalizeUnicode(value ?? '');
|
|
108
116
|
const context = parseHousingContext(text);
|
|
109
117
|
const features = parseHousingFeatures(text);
|
|
110
118
|
if (!text) return deepFreeze({});
|
|
119
|
+
// Utility billing is a rental-only concept. A source may carry a generic
|
|
120
|
+
// payment-table value on every listing, including sales, so never let that
|
|
121
|
+
// value become a persisted housing attribute for a sale.
|
|
122
|
+
const resolvedDealType = dealType || resolveHousingIntent(text)?.dealType || null;
|
|
123
|
+
const isSale = resolvedDealType === 'sale';
|
|
111
124
|
|
|
112
125
|
const gas = bool(text,
|
|
113
126
|
/(?:^|[^\p{L}\p{N}_])(?:газ|gaz|gas)(?=$|[^\p{L}\p{N}_])|метан|aragaz|gaz\s+ta['’]?min/iu,
|
|
@@ -211,7 +224,7 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
|
211
224
|
),
|
|
212
225
|
gas,
|
|
213
226
|
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),
|
|
214
|
-
communalSeparated: parseCommunalSeparated(text, country),
|
|
227
|
+
communalSeparated: isSale ? null : parseCommunalSeparated(text, country),
|
|
215
228
|
parking: bool(
|
|
216
229
|
text,
|
|
217
230
|
/паркинг|парков|машино[- ]?мест|parking|avtoturargoh|mashina\s*joyi/iu,
|
|
@@ -239,6 +252,6 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
|
239
252
|
firstRent,
|
|
240
253
|
minRentTerm: parseMinRentTerm(text),
|
|
241
254
|
availableFrom: parseAvailableFrom(text),
|
|
242
|
-
utilitiesAmount: parseUtilitiesAmount(text),
|
|
255
|
+
utilitiesAmount: isSale ? null : parseUtilitiesAmount(text),
|
|
243
256
|
});
|
|
244
257
|
}
|
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,9 +318,6 @@ 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
323
|
const amount = parseScaledAmount(match[1], match[2]);
|
|
@@ -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();
|
|
@@ -430,6 +414,17 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
430
414
|
const country = String(options.country || '').trim();
|
|
431
415
|
const fallbackCurrency = options.fallbackCurrency || countryCurrency(country) || '';
|
|
432
416
|
const phoneCountry = options.phoneCountry || countryPhoneHint(country) || null;
|
|
417
|
+
const intent = resolveHousingIntent(text);
|
|
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 });
|
|
423
|
+
const parsedPayments = parseHousingPaymentDetails(text);
|
|
424
|
+
const payments = effectiveDealType === 'sale'
|
|
425
|
+
? deepFreeze({ ...parsedPayments, utilities: null })
|
|
426
|
+
: parsedPayments;
|
|
427
|
+
const numericOptions = { country, dealType: effectiveDealType };
|
|
433
428
|
|
|
434
429
|
return deepFreeze({
|
|
435
430
|
text,
|
|
@@ -437,12 +432,16 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
437
432
|
platform: sourcePost.source,
|
|
438
433
|
contact: sourcePost.contact,
|
|
439
434
|
},
|
|
440
|
-
intent
|
|
435
|
+
intent,
|
|
441
436
|
context: parseHousingContext(text),
|
|
442
437
|
rooms: parseHousingRoomCount(text),
|
|
443
438
|
floor: parseHousingFloor(text),
|
|
444
|
-
area: parseHousingAreas(text),
|
|
445
|
-
price: parseHousingPrice(text,
|
|
439
|
+
area: parseHousingAreas(text, numericOptions),
|
|
440
|
+
price: parseHousingPrice(text, {
|
|
441
|
+
country,
|
|
442
|
+
currency: fallbackCurrency,
|
|
443
|
+
dealType: effectiveDealType,
|
|
444
|
+
}),
|
|
446
445
|
address: parseHousingAddress(text, {
|
|
447
446
|
knownStreet: options.knownStreet || null,
|
|
448
447
|
allowDelimitedBare: options.allowDelimitedBareAddress === true,
|
|
@@ -450,8 +449,8 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
450
449
|
}),
|
|
451
450
|
residentialComplex: parseHousingResidentialComplex(text),
|
|
452
451
|
amenities: parseHousingAmenities(text),
|
|
453
|
-
listingFields
|
|
454
|
-
payments
|
|
452
|
+
listingFields,
|
|
453
|
+
payments,
|
|
455
454
|
seller: parseHousingSeller(text),
|
|
456
455
|
infrastructure: parseHousingInfrastructure(text),
|
|
457
456
|
contacts: parseHousingContacts(text, { countryHint: phoneCountry, sourcePost }),
|
package/src/housing-text.js
CHANGED
|
@@ -33,6 +33,7 @@ const RC_ATTRIBUTE_TOKEN_RE = /^\d|^[\d/]+$|^[a-zа-яё]?\d+(?:[а-яёa-z]{1,4
|
|
|
33
33
|
const RC_LATIN_NOISE_RE = /^(?:vip|lux|luxe|elite|premium|euro|evro|new|top|super|best|hot|urgent|srochno|arenda|ijara|sotiladi|sale|rent|for|home|house|flat|apartment|apartments|kvartira|tashkent|toshkent|wifi|wi|fi|tv|ac|internet|telegram|yandex|google|instagram|whatsapp|iphone|samsung|lg|bosch|artel|km|km2|m2|sqm|usd|uzs|eur)$/iu;
|
|
34
34
|
const RC_LATIN_WORD_RE = /^[A-Za-z][A-Za-z'’\-]{1,}$/u;
|
|
35
35
|
const RC_DISTRICT_MARKER_RE = /^(?:район|районе|районы|туман|тумани|tumani|district)$/iu;
|
|
36
|
+
const RC_ADDRESS_INTRO_RE = /^(?:за|адрес(?:ою|у|ом)?|вул(?:иця)?|улица)$/iu;
|
|
36
37
|
const RC_MARKER_RE = /(?:жк|жм|ж\/к|residential complex|ansamblu(?: rezidential)?|turar[- ]?joy majmuasi)\s*/i;
|
|
37
38
|
|
|
38
39
|
function leadingLatinName(text) {
|
|
@@ -65,7 +66,7 @@ export function parseHousingResidentialComplex(value) {
|
|
|
65
66
|
const parsed = String(candidate).trim().replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2').replace(/\s{2,}/g, ' ').split(/\s+/).reduce((words, token) => {
|
|
66
67
|
if (words.stopped || words.list.length >= 4) return { ...words, stopped: true };
|
|
67
68
|
const clean = token.replace(/^[«»"'„“]+|[«»"'„“!|,]+$/g, '');
|
|
68
|
-
if (!clean || RC_STOP_WORD_RE.test(clean) || RC_ATTRIBUTE_TOKEN_RE.test(clean)) return { ...words, stopped: true, by: clean };
|
|
69
|
+
if (!clean || RC_STOP_WORD_RE.test(clean) || RC_ADDRESS_INTRO_RE.test(clean) || RC_ATTRIBUTE_TOKEN_RE.test(clean)) return { ...words, stopped: true, by: clean };
|
|
69
70
|
return { list: [...words.list, clean], stopped: false, by: words.by };
|
|
70
71
|
}, { list: [], stopped: false, by: '' });
|
|
71
72
|
const words = RC_DISTRICT_MARKER_RE.test(parsed.by) ? parsed.list.slice(0, -1) : parsed.list;
|
|
@@ -39,6 +39,8 @@ export const UA_KHARKIV_LOCATION_TRANSLATIONS = Object.freeze({
|
|
|
39
39
|
['Horizont', 'Обрій', 'Обрий', 'Obrii', 'Горизонт'],
|
|
40
40
|
]),
|
|
41
41
|
streets: locationEntries([
|
|
42
|
+
['Poltavskyi Shliakh Street', 'Полтавський шлях', 'Полтавский шлях', 'Полтавський шоях', 'Полтавский шоях'],
|
|
43
|
+
['Liubovi Maloi Street', 'вулиця Любові Малої', 'вул. Любові Малої', 'Любові Малої', 'улица Любови Малой', 'ул. Любови Малой', 'Любови Малой', 'Л. Малой', 'Л.Малой', 'ул. Л. Малой', 'ул.Л.Малой', 'улица Л. Малой', 'улица Л.Малой'],
|
|
42
44
|
['Hvardiitsiv-Shyronintsiv Street', 'вулиця Гвардійців-Широнінців', 'вул. Гвардійців-Широнінців', 'Гвардійців-Широнінців', 'улица Гвардейцев-Широнинцев', 'ул. Гвардейцев-Широнинцев', 'Гвардейцев-Широнинцев', 'Гвардейцев Широнинцев'],
|
|
43
45
|
['Yuvileinyi Avenue', 'проспект Ювілейний', 'пр-т Ювілейний', 'Ювілейний проспект', 'проспект Юбилейный', 'пр-т Юбилейный', 'Юбилейный проспект', 'проспект 50-річчя ВЛКСМ', 'проспект 50-летия ВЛКСМ'],
|
|
44
46
|
['Traktorobudivnykiv Avenue', 'проспект Тракторобудівників', 'пр-т Тракторобудівників', 'Тракторобудівників проспект', 'проспект Тракторостроителей', 'пр-т Тракторостроителей', 'Тракторостроителей проспект'],
|
|
@@ -3,6 +3,7 @@ import { locationEntries } from './location-merge.js';
|
|
|
3
3
|
export const UA_KHARKIV_MICRODISTRICT_EXTENSIONS = Object.freeze({
|
|
4
4
|
Kharkiv: Object.freeze({
|
|
5
5
|
microdistricts: locationEntries([
|
|
6
|
+
['Kholodna Hora', 'Хол. Гора', 'Хол Гора'],
|
|
6
7
|
['337 microdistrict', '337-й мікрорайон', '337 мікрорайон', '337-й микрорайон', '337 микрорайон', '337 мкр', '337 мкр.', '337-й мкр', '337 м/р', '337 мрн'],
|
|
7
8
|
['339 microdistrict', '339-й мікрорайон', '339 мікрорайон', '339-й микрорайон', '339 микрорайон', '339 мкр', '339 мкр.', '339-й мкр', '339 м/р', '339 мрн'],
|
|
8
9
|
]),
|