@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 +1 -1
- package/src/housing-address.d.ts +2 -0
- package/src/housing-address.js +5 -0
- package/src/housing-listing-enrichment.d.ts +2 -0
- package/src/housing-listing-enrichment.js +13 -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';
|
|
@@ -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
|
-
|
|
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
|
'Ташкент Северный вокзал',
|