@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 +1 -1
- package/src/housing-address.d.ts +2 -0
- package/src/housing-address.js +33 -5
- package/src/housing-listing-enrichment.d.ts +2 -0
- package/src/housing-listing-enrichment.js +16 -2
- package/src/housing-money.js +3 -2
- package/src/housing-structured.js +2 -1
- package/src/tashkent-housing-geography.d.ts +1 -0
- package/src/tashkent-housing-geography.js +57 -24
package/package.json
CHANGED
package/src/housing-address.d.ts
CHANGED
|
@@ -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;
|
package/src/housing-address.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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,
|
package/src/housing-money.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
'Ташкент Северный вокзал',
|