@whiteslove/parsing-lexicon 0.6.15 → 0.6.16

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.16",
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
  }
@@ -242,8 +242,17 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
242
242
  // `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
243
243
  const match = priceText.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
244
244
  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 {
245
253
  const amount = parseScaledAmount(match[1], match[2]);
246
254
  if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
255
+ }
247
256
  }
248
257
  }
249
258
 
@@ -430,6 +430,12 @@ export function parseHousingStructured(value, options = {}) {
430
430
  const country = String(options.country || '').trim();
431
431
  const fallbackCurrency = options.fallbackCurrency || countryCurrency(country) || '';
432
432
  const phoneCountry = options.phoneCountry || countryPhoneHint(country) || null;
433
+ const intent = resolveHousingIntent(text);
434
+ const listingFields = parseHousingListingFields(text, { country, dealType: intent?.dealType || null });
435
+ const parsedPayments = parseHousingPaymentDetails(text);
436
+ const payments = intent?.dealType === 'sale'
437
+ ? deepFreeze({ ...parsedPayments, utilities: null })
438
+ : parsedPayments;
433
439
 
434
440
  return deepFreeze({
435
441
  text,
@@ -437,7 +443,7 @@ export function parseHousingStructured(value, options = {}) {
437
443
  platform: sourcePost.source,
438
444
  contact: sourcePost.contact,
439
445
  },
440
- intent: resolveHousingIntent(text),
446
+ intent,
441
447
  context: parseHousingContext(text),
442
448
  rooms: parseHousingRoomCount(text),
443
449
  floor: parseHousingFloor(text),
@@ -450,8 +456,8 @@ export function parseHousingStructured(value, options = {}) {
450
456
  }),
451
457
  residentialComplex: parseHousingResidentialComplex(text),
452
458
  amenities: parseHousingAmenities(text),
453
- listingFields: parseHousingListingFields(text, { country }),
454
- payments: parseHousingPaymentDetails(text),
459
+ listingFields,
460
+ payments,
455
461
  seller: parseHousingSeller(text),
456
462
  infrastructure: parseHousingInfrastructure(text),
457
463
  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
  ]),