@whiteslove/parsing-lexicon 0.10.5 → 0.10.7

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.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,6 +28,8 @@ const POSTFIX_STREET_MARKER = `(?:${combinedMarkerPattern(STREET_POSTFIX_MARKERS
28
28
  const POSTFIX_STREET_TYPE = `(?:${combinedMarkerPattern(STREET_TYPE_MARKERS)})`;
29
29
  const HOUSE_MARKER = `(?:${combinedMarkerPattern(HOUSE_MARKERS)})`;
30
30
  const BUILDING_MARKER = `(?:${combinedMarkerPattern(BUILDING_MARKERS)})`;
31
+ // Uzbek/Kazakh put the house word after the number: "12-uy", "45 uy".
32
+ const HOUSE_NUMBER_SUFFIX = String.raw`(?:\s*-?\s*(?:uy|уй|үй)(?![\p{L}\p{N}]))?`;
31
33
  const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}]\d{0,4})?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}]\d{0,4})?){0,2}`;
32
34
  const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
33
35
  // Common post-Soviet street names lead with a bare numeral ("8 Марта",
@@ -338,12 +340,31 @@ function attachCatalogReferences(parsed, options) {
338
340
  return Object.keys(geoEntities).length ? Object.freeze({ ...parsed, geoEntities }) : parsed;
339
341
  }
340
342
 
343
+ // "Sergeli 5 104": numbered massif followed by the house number.
344
+ function tashkentAreaThenHouseAddress(text) {
345
+ const re = /(?:^|[^\p{L}\p{N}_])([\p{L}'’ʻʼ-]+)\s+(\d{1,2})\s*[,;]?\s+(?:(?:dom|дом|uy|уй)\s*)?(\d{1,4})([\p{L}])?(?=$|[^\p{L}\p{N}_/])/giu;
346
+ for (const match of text.matchAll(re)) {
347
+ const quarterNumber = Number(match[2]);
348
+ const district = Object.keys(TASHKENT_NUMBERED_AREA_ALIASES).find((canonical) => (
349
+ matchTashkentNumberedArea(`${match[1]} ${quarterNumber}`, canonical)?.number === quarterNumber
350
+ ));
351
+ if (!district) continue;
352
+ return Object.freeze({
353
+ ...result(null, null, `${Number(match[3])}${match[4] ? match[4].toUpperCase() : ''}`, null,
354
+ scoreAddressConfidence(text, { source: 'structured', hasHouse: true })),
355
+ district,
356
+ quarter: Object.freeze({ number: quarterNumber, suffix: matchTashkentNumberedArea(`${match[1]} ${quarterNumber}`, district)?.suffix || '' }),
357
+ });
358
+ }
359
+ return null;
360
+ }
361
+
341
362
  function tashkentMassifHouseAddress(value) {
342
363
  const text = String(value ?? '');
343
364
  const match = text.match(
344
365
  /(?:^|[^\p{L}\p{N}_])(\d{1,2})\s+(?:mavze(?:si)?|мавзе(?:си)?)\s+(\d{1,5})\s*([\p{L}])?\s*(?:dom|дом|uy|уй)(?=$|[^\p{L}\p{N}_])/iu,
345
366
  );
346
- if (!match) return null;
367
+ if (!match) return tashkentAreaThenHouseAddress(text);
347
368
 
348
369
  const quarterNumber = Number(match[1]);
349
370
  const district = Object.keys(TASHKENT_NUMBERED_AREA_ALIASES).find((canonical) => {
@@ -371,7 +392,7 @@ function tashkentMassifHouseAddress(value) {
371
392
  }
372
393
 
373
394
  function splitAddressTail(raw) {
374
- const text = clean(raw);
395
+ const text = clean(raw).replace(new RegExp(`(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}(?=\\s*[,;]|\\s*$)`, 'iu'), '$1');
375
396
  if (!text) return null;
376
397
 
377
398
  const buildingRe = new RegExp(`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))\\s*$`, 'iu');
@@ -400,7 +421,7 @@ function splitAddressTail(raw) {
400
421
  function postfixTypedStreetAddress(line) {
401
422
  const suffix = line.match(new RegExp(
402
423
  `(?:^|[^\\p{L}\\p{N}])((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
403
- `\\s*[,;]?\\s*(${NUMBER_TOKEN})` +
424
+ `\\s*[,;]?\\s*(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}` +
404
425
  `(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
405
426
  `(?=$|[^\\p{L}\\p{N}])`,
406
427
  'iu',
@@ -424,7 +445,7 @@ function prefixTypedStreetAddress(line) {
424
445
  const prefix = line.match(new RegExp(
425
446
  `(?:^|[\\s,;])${PREFIX_STREET_MARKER}\\s+` +
426
447
  `((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
427
- `\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})` +
448
+ `\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}` +
428
449
  `(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
429
450
  `(?=$|[^\\p{L}\\p{N}])`,
430
451
  'iu',
@@ -529,7 +550,12 @@ function collectExplicitStreetCandidates(text) {
529
550
 
530
551
  const postfix = line.match(new RegExp(`^(.+?)\\s+(${POSTFIX_STREET_MARKER})(.*)$`, 'iu'));
531
552
  if (postfix) {
532
- const tailText = clean(`${postfix[1]} ${postfix[3]}`);
553
+ // Whatever follows the first comma ("..., Sergeli") is a district or
554
+ // landmark, not part of the street/house.
555
+ // A building marker after the comma ("58, korpus 2") still belongs to it.
556
+ const afterStreet = postfix[3];
557
+ const cut = afterStreet.search(new RegExp(`[,;](?!\\s*${BUILDING_MARKER}\\s*${NUMBER_TOKEN})`, 'iu'));
558
+ const tailText = clean(`${postfix[1]} ${cut < 0 ? afterStreet : afterStreet.slice(0, cut)}`);
533
559
  const tail = splitAddressTail(tailText);
534
560
  if (tail) {
535
561
  add(result(
@@ -209,7 +209,9 @@ export function parseHousingAudience(value) {
209
209
  if (family && students) return deepFreeze({ primary: 'family', alternatives: ['family', 'students'] });
210
210
  if (family && women) return deepFreeze({ primary: 'family', alternatives: ['family', 'women'] });
211
211
  // "Student qizlarga" narrows students to girls: the gender is the binding
212
- // restriction, and studentTarget still reports the student part.
212
+ // primary restriction, but the student audience stays in the alternatives.
213
+ if (students && women && !men) return deepFreeze({ primary: 'women', alternatives: ['women', 'students'] });
214
+ if (students && men && !women) return deepFreeze({ primary: 'men', alternatives: ['men', 'students'] });
213
215
  if (students && !women && !men) return deepFreeze({ primary: 'students', alternatives: ['students'] });
214
216
  if (family) return deepFreeze({ primary: 'family', alternatives: ['family'] });
215
217
  if (women) return deepFreeze({ primary: 'women', alternatives: ['women'] });
@@ -634,8 +634,9 @@ export function parseHousingPrice(value, context = '') {
634
634
  if (!explicit && !hasExplicitUzbekThousandScale && fallbackCurrency === 'UZS' && price != null) {
635
635
  const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(priceText);
636
636
  // A monthly rent of 100,000+ is only plausible in so'm; the USD reading
637
- // turned "800 000" per person into an 800,000 USD rent.
638
- const monthlyRent = dealType === 'longRent' && price >= 100_000;
637
+ // turned "800 000" per person into an 800,000 USD rent. When the deal type
638
+ // is unknown, 100,000+ is still so'm: only a known sale may be dollars.
639
+ const monthlyRent = dealType !== 'sale' && price >= 100_000;
639
640
  currency = price >= 1_000_000 || monthlyRent || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
640
641
  } else if (!explicit && currency === fallbackCurrency) {
641
642
  currency = lowLocalAmountCurrency(price, currency, dealType, priceText);
@@ -342,7 +342,8 @@ function landmarkIdentity(text, match) {
342
342
  if (numbered) {
343
343
  const localStart = before.length - numbered[0].length;
344
344
  const previous = localStart > 0 ? before[localStart - 1] : '';
345
- if (!previous || !/[\p{L}\p{N}_]/u.test(previous)) {
345
+ // "24/7 korzinka" is an opening-hours phrase, not branch number 7.
346
+ if (!previous || !/[\p{L}\p{N}_/]/u.test(previous)) {
346
347
  number = Number(numbered[1]);
347
348
  rawStart = match.start - numbered[0].length;
348
349
  }