@whiteslove/parsing-lexicon 0.10.4 → 0.10.5

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.5",
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';
@@ -246,10 +247,14 @@ function tashkentGeoComponents(value) {
246
247
  const text = String(value ?? '');
247
248
  const district = matchTashkentHousingDistrict(text)?.name || null;
248
249
  const metro = matchTashkentHousingMetro(text)?.name || null;
250
+ const metros = metro
251
+ ? [...new Set([metro, ...matchTashkentHousingMetros(text).map((station) => station.name)])]
252
+ : [];
249
253
  const mahalla = text.match(/(?:^|[^\p{L}])(\p{L}[\p{L}'’ʼ-]{1,48})\s+(?:mahalla(?:si)?|маҳалла(?:си)?|махалл[ая]|mfy)(?=$|[^\p{L}])/iu)?.[1] || null;
250
254
  return Object.freeze({
251
255
  district,
252
256
  metro,
257
+ metros: metros.length ? Object.freeze(metros) : null,
253
258
  mahalla: DESCRIPTIVE_MAHALLA_WORD_RE.test(mahalla || '') ? null : compactStreet(mahalla),
254
259
  });
255
260
  }
@@ -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;
@@ -310,6 +320,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
310
320
  const quarter = matchTashkentHousingQuarter(text);
311
321
  const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
312
322
  const metro = matchTashkentHousingMetro(text)?.name || cityMetro(text, country, city) || null;
323
+ const metros = listingMetros(text, country, city, metro);
313
324
  const developmentArea = cityDevelopmentArea(text, country, city);
314
325
  const primaryResidentialText = withoutNearbyLocationReferences(text);
315
326
  const parsedRc = specificResidentialComplex(primaryResidentialText)
@@ -386,6 +397,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
386
397
  district: district || null,
387
398
  quarter: quarter ? { number: quarter.number, suffix: quarter.suffix } : null,
388
399
  metro: metro || null,
400
+ metros,
389
401
  developmentArea,
390
402
  residenceComplex: parsedRc || null,
391
403
  address: address.address,
@@ -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
  'Ташкент Северный вокзал',