@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.6.15",
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",
@@ -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(value: unknown, options?: { country?: string }): Readonly<HousingListingEnrichment>;
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 listingFields = parseHousingListingFields(text, { country });
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
- const match = text.match(/(?:коммунальн\p{L}*|коммуналк\p{L}*|ком\.?\s*услуг\p{L}*|utilities?|bills?)\D{0,24}(?:около|примерно|~|≈)?\s*(\d{2,8}(?:[.,]\d{1,2})?)\s*(₴|грн|uah|\$|usd|€|eur|сум|uzs|тг|kzt|lei|ron|руб|rub)?/iu);
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
  }
@@ -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,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(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();
@@ -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: resolveHousingIntent(text),
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, fallbackCurrency),
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: parseHousingListingFields(text, { country }),
454
- payments: parseHousingPaymentDetails(text),
452
+ listingFields,
453
+ payments,
455
454
  seller: parseHousingSeller(text),
456
455
  infrastructure: parseHousingInfrastructure(text),
457
456
  contacts: parseHousingContacts(text, { countryHint: phoneCountry, sourcePost }),
@@ -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
  ]),