@whiteslove/parsing-lexicon 0.10.4 → 0.10.6

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.4",
3
+ "version": "0.10.6",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,8 @@ export type HousingAddressParts = Readonly<{
10
10
  staircase?: string;
11
11
  district?: string;
12
12
  metro?: string;
13
+ /** Every metro station named, primary (`metro`) first. */
14
+ metros?: readonly string[];
13
15
  mahalla?: string;
14
16
  geoEntities?: Readonly<Partial<Record<'street' | 'district' | 'metro' | 'mahalla' | 'residentialComplex', Readonly<{
15
17
  id: string;
@@ -2,6 +2,7 @@ import {
2
2
  TASHKENT_NUMBERED_AREA_ALIASES,
3
3
  matchTashkentHousingDistrict,
4
4
  matchTashkentHousingMetro,
5
+ matchTashkentHousingMetros,
5
6
  matchTashkentNumberedArea,
6
7
  } from './tashkent-housing-geography.js';
7
8
  import { detectNonAddressSpans } from './semantic-spans.js';
@@ -27,6 +28,8 @@ const POSTFIX_STREET_MARKER = `(?:${combinedMarkerPattern(STREET_POSTFIX_MARKERS
27
28
  const POSTFIX_STREET_TYPE = `(?:${combinedMarkerPattern(STREET_TYPE_MARKERS)})`;
28
29
  const HOUSE_MARKER = `(?:${combinedMarkerPattern(HOUSE_MARKERS)})`;
29
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}]))?`;
30
33
  const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}]\d{0,4})?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}]\d{0,4})?){0,2}`;
31
34
  const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
32
35
  // Common post-Soviet street names lead with a bare numeral ("8 Марта",
@@ -246,10 +249,14 @@ function tashkentGeoComponents(value) {
246
249
  const text = String(value ?? '');
247
250
  const district = matchTashkentHousingDistrict(text)?.name || null;
248
251
  const metro = matchTashkentHousingMetro(text)?.name || null;
252
+ const metros = metro
253
+ ? [...new Set([metro, ...matchTashkentHousingMetros(text).map((station) => station.name)])]
254
+ : [];
249
255
  const mahalla = text.match(/(?:^|[^\p{L}])(\p{L}[\p{L}'’ʼ-]{1,48})\s+(?:mahalla(?:si)?|маҳалла(?:си)?|махалл[ая]|mfy)(?=$|[^\p{L}])/iu)?.[1] || null;
250
256
  return Object.freeze({
251
257
  district,
252
258
  metro,
259
+ metros: metros.length ? Object.freeze(metros) : null,
253
260
  mahalla: DESCRIPTIVE_MAHALLA_WORD_RE.test(mahalla || '') ? null : compactStreet(mahalla),
254
261
  });
255
262
  }
@@ -333,12 +340,31 @@ function attachCatalogReferences(parsed, options) {
333
340
  return Object.keys(geoEntities).length ? Object.freeze({ ...parsed, geoEntities }) : parsed;
334
341
  }
335
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
+
336
362
  function tashkentMassifHouseAddress(value) {
337
363
  const text = String(value ?? '');
338
364
  const match = text.match(
339
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,
340
366
  );
341
- if (!match) return null;
367
+ if (!match) return tashkentAreaThenHouseAddress(text);
342
368
 
343
369
  const quarterNumber = Number(match[1]);
344
370
  const district = Object.keys(TASHKENT_NUMBERED_AREA_ALIASES).find((canonical) => {
@@ -366,7 +392,7 @@ function tashkentMassifHouseAddress(value) {
366
392
  }
367
393
 
368
394
  function splitAddressTail(raw) {
369
- const text = clean(raw);
395
+ const text = clean(raw).replace(new RegExp(`(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}(?=\\s*[,;]|\\s*$)`, 'iu'), '$1');
370
396
  if (!text) return null;
371
397
 
372
398
  const buildingRe = new RegExp(`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))\\s*$`, 'iu');
@@ -395,7 +421,7 @@ function splitAddressTail(raw) {
395
421
  function postfixTypedStreetAddress(line) {
396
422
  const suffix = line.match(new RegExp(
397
423
  `(?:^|[^\\p{L}\\p{N}])((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
398
- `\\s*[,;]?\\s*(${NUMBER_TOKEN})` +
424
+ `\\s*[,;]?\\s*(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}` +
399
425
  `(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
400
426
  `(?=$|[^\\p{L}\\p{N}])`,
401
427
  'iu',
@@ -419,7 +445,7 @@ function prefixTypedStreetAddress(line) {
419
445
  const prefix = line.match(new RegExp(
420
446
  `(?:^|[\\s,;])${PREFIX_STREET_MARKER}\\s+` +
421
447
  `((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
422
- `\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})` +
448
+ `\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})${HOUSE_NUMBER_SUFFIX}` +
423
449
  `(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
424
450
  `(?=$|[^\\p{L}\\p{N}])`,
425
451
  'iu',
@@ -524,7 +550,9 @@ function collectExplicitStreetCandidates(text) {
524
550
 
525
551
  const postfix = line.match(new RegExp(`^(.+?)\\s+(${POSTFIX_STREET_MARKER})(.*)$`, 'iu'));
526
552
  if (postfix) {
527
- 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
+ const tailText = clean(`${postfix[1]} ${postfix[3].split(/[,;]/u, 1)[0]}`);
528
556
  const tail = splitAddressTail(tailText);
529
557
  if (tail) {
530
558
  add(result(
@@ -58,6 +58,8 @@ export interface HousingListingEnrichment {
58
58
  district?: string | null;
59
59
  quarter?: { number: number; suffix: string } | null;
60
60
  metro?: string | null;
61
+ /** Every metro station named, primary (`metro`) first. */
62
+ metros?: readonly string[];
61
63
  developmentArea?: string | null;
62
64
  residenceComplex?: string | null;
63
65
  address?: string | null;
@@ -1,7 +1,7 @@
1
1
  import { deepFreeze } from './lexicon-core.js';
2
2
  import { findAllCanonical, normalizeUnicode } from './normalization.js';
3
3
  import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
4
- import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
4
+ import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingMetros, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
5
5
  import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
6
6
  import { TASHKENT_RESIDENTIAL_COMPLEXES, matchTashkentResidentialComplex } from './tashkent-residential-complexes.js';
7
7
  import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
@@ -161,6 +161,16 @@ function cityMetro(text, country, city) {
161
161
  return contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE)[0]?.canonical || null;
162
162
  }
163
163
 
164
+ // Every station the listing names, primary first. A flat "between Nizomiy and
165
+ // Chilonzor" must keep both rather than silently dropping the second.
166
+ function listingMetros(text, country, city, primary) {
167
+ const names = [
168
+ ...matchTashkentHousingMetros(text).map((station) => station.name),
169
+ ...contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE).map((match) => match.canonical),
170
+ ];
171
+ return Object.freeze(unique(primary ? [primary, ...names] : names));
172
+ }
173
+
164
174
  function cityDevelopmentArea(text, country, city) {
165
175
  const normalizedCountry = String(country || '').toUpperCase();
166
176
  if (!['KZ', 'UZ'].includes(normalizedCountry) || !city) return null;
@@ -199,7 +209,9 @@ export function parseHousingAudience(value) {
199
209
  if (family && students) return deepFreeze({ primary: 'family', alternatives: ['family', 'students'] });
200
210
  if (family && women) return deepFreeze({ primary: 'family', alternatives: ['family', 'women'] });
201
211
  // "Student qizlarga" narrows students to girls: the gender is the binding
202
- // 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'] });
203
215
  if (students && !women && !men) return deepFreeze({ primary: 'students', alternatives: ['students'] });
204
216
  if (family) return deepFreeze({ primary: 'family', alternatives: ['family'] });
205
217
  if (women) return deepFreeze({ primary: 'women', alternatives: ['women'] });
@@ -310,6 +322,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
310
322
  const quarter = matchTashkentHousingQuarter(text);
311
323
  const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
312
324
  const metro = matchTashkentHousingMetro(text)?.name || cityMetro(text, country, city) || null;
325
+ const metros = listingMetros(text, country, city, metro);
313
326
  const developmentArea = cityDevelopmentArea(text, country, city);
314
327
  const primaryResidentialText = withoutNearbyLocationReferences(text);
315
328
  const parsedRc = specificResidentialComplex(primaryResidentialText)
@@ -386,6 +399,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
386
399
  district: district || null,
387
400
  quarter: quarter ? { number: quarter.number, suffix: quarter.suffix } : null,
388
401
  metro: metro || null,
402
+ metros,
389
403
  developmentArea,
390
404
  residenceComplex: parsedRc || null,
391
405
  address: address.address,
@@ -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
  }
@@ -46,4 +46,5 @@ export function hasExplicitTashkentDistrict(value: unknown, canonical: string):
46
46
  export function matchTashkentHousingDistrict(value: unknown): TashkentDistrictEntry | null;
47
47
  export function matchTashkentHousingQuarter(value: unknown): Readonly<{ district: string; number: number; suffix: string }> | null;
48
48
  export function matchTashkentHousingMetro(value: unknown): TashkentMetroEntry | null;
49
+ export function matchTashkentHousingMetros(value: unknown): ReadonlyArray<TashkentMetroEntry>;
49
50
  export function matchTashkentHousingTransit(value: unknown): TashkentHousingLocationEntry | null;
@@ -298,42 +298,75 @@ const METRO_NUMBERED_AREA = Object.freeze({
298
298
  Yangihayot: 'Yangihayot',
299
299
  });
300
300
 
301
+ // Position of the first mention of `station` that really names the station,
302
+ // or -1. An explicit marker must win even if an earlier bare occurrence shares
303
+ // a name with a district: "Sergeli tumani, metro Sergeli" still refers to the
304
+ // station at the second occurrence.
305
+ function stationMentionIndex(text, station) {
306
+ const flags = [...new Set(`${station.re.flags.replace(/g/gu, '')}g`)].join('');
307
+ const matches = [...text.matchAll(new RegExp(station.re.source, flags))];
308
+ if (!matches.length) return -1;
309
+ const explicit = matches.find((match) => hasExplicitMetroContext(text, match));
310
+ if (explicit) return explicit.index ?? 0;
311
+ if (hasExplicitTashkentDistrict(text, station.name)) return -1;
312
+ // "Toshkent" is both a metro station and the city's own name, so a bare
313
+ // mention ("Toshkent shahri") is not evidence of the station the way a bare
314
+ // mention of any other station name would be. Require an explicit metro
315
+ // context for this one station specifically.
316
+ if (station.name === 'Toshkent') return -1;
317
+ if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) return -1;
318
+ const areaCanonical = METRO_NUMBERED_AREA[station.name];
319
+ if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) return -1;
320
+ for (const match of matches) {
321
+ if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
322
+ return match.index ?? 0;
323
+ }
324
+ return -1;
325
+ }
326
+
327
+ function extraAliasMentionIndex(text, aliases) {
328
+ const match = text.match(aliasesToRegex(aliases));
329
+ if (!match) return -1;
330
+ if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) return -1;
331
+ return match.index ?? 0;
332
+ }
333
+
301
334
  /** Resolve listing typos/transliterations while respecting explicit non-metro geography. */
302
335
  export function matchTashkentHousingMetro(value) {
303
336
  const text = String(value ?? '');
304
337
  if (!text) return null;
305
338
  for (const station of TASHKENT_METRO) {
306
- const flags = [...new Set(`${station.re.flags.replace(/g/gu, '')}g`)].join('');
307
- const matches = [...text.matchAll(new RegExp(station.re.source, flags))];
308
- if (!matches.length) continue;
309
- // An explicit marker must win even if an earlier bare occurrence shares a
310
- // name with a district. For example, "Sergeli tumani, metro Sergeli"
311
- // still refers to the station at the second occurrence.
312
- if (matches.some((match) => hasExplicitMetroContext(text, match))) return station;
313
- const sameNamedDistrict = hasExplicitTashkentDistrict(text, station.name);
314
- for (const match of matches) {
315
- if (sameNamedDistrict) continue;
316
- // "Toshkent" is both a metro station and the city's own name, so a bare
317
- // mention ("Toshkent shahri") is not evidence of the station the way a
318
- // bare mention of any other station name would be. Require an explicit
319
- // metro context for this one station specifically.
320
- if (station.name === 'Toshkent') continue;
321
- if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
322
- const areaCanonical = METRO_NUMBERED_AREA[station.name];
323
- if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
324
- if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
325
- return station;
326
- }
339
+ if (stationMentionIndex(text, station) !== -1) return station;
327
340
  }
328
341
  for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
329
- const match = text.match(aliasesToRegex(aliases));
330
- if (!match) continue;
331
- if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) continue;
342
+ if (extraAliasMentionIndex(text, aliases) === -1) continue;
332
343
  return TASHKENT_METRO.find((station) => station.name === canonical) || null;
333
344
  }
334
345
  return null;
335
346
  }
336
347
 
348
+ /**
349
+ * Every Tashkent metro station a listing names, in text order. Listings often
350
+ * sit between stations ("Nizomiy & Chilonzor metrosiga yaqin"); the singular
351
+ * matcher above keeps its historical first-station contract.
352
+ */
353
+ export function matchTashkentHousingMetros(value) {
354
+ const text = String(value ?? '');
355
+ if (!text) return Object.freeze([]);
356
+ const found = new Map();
357
+ for (const station of TASHKENT_METRO) {
358
+ const index = stationMentionIndex(text, station);
359
+ if (index !== -1) found.set(station.name, { station, index });
360
+ }
361
+ for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
362
+ if (found.has(canonical)) continue;
363
+ const index = extraAliasMentionIndex(text, aliases);
364
+ const station = TASHKENT_METRO.find((entry) => entry.name === canonical);
365
+ if (index !== -1 && station) found.set(canonical, { station, index });
366
+ }
367
+ return Object.freeze([...found.values()].sort((a, b) => a.index - b.index).map((item) => item.station));
368
+ }
369
+
337
370
  const TASHKENT_HOUSING_TRANSIT = Object.freeze([
338
371
  locationEntry('Tashkent North Railway Station', 'railway', [
339
372
  'Ташкент Северный вокзал',