@whiteslove/parsing-lexicon 0.6.4 → 0.6.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/central-asia-locations.js +86 -2
- package/src/housing-card-fields.js +20 -4
- package/src/housing-listing-enrichment.js +45 -2
- package/src/housing-poi-extensions.js +6 -0
- package/src/housing-structured.js +18 -1
- package/src/locations-runtime.js +5 -0
- package/src/ua-city-location-expansions.js +8 -0
- package/src/uz-tashkent-context-extensions.js +37 -0
package/package.json
CHANGED
|
@@ -139,6 +139,86 @@ function semanticBounds(value, match) {
|
|
|
139
139
|
return { start, end };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
const NUMBERED_LOCAL_AREA_NAME_RE = /(?:^|[-–—/\s])\d{1,2}[aа]?(?=$|[^\p{L}\p{N}_])/iu;
|
|
143
|
+
const NUMBERED_CHILD_SUFFIX_RE = /^\s*[-–—/]\s*\d{1,2}[aа]?(?=$|[^\p{L}\p{N}_])/iu;
|
|
144
|
+
|
|
145
|
+
function isUmbrellaAreaPrefixOfNumberedBlock(value, candidate) {
|
|
146
|
+
if (candidate.type !== 'local_area') return false;
|
|
147
|
+
if (NUMBERED_LOCAL_AREA_NAME_RE.test(String(candidate.name || ''))) return false;
|
|
148
|
+
return NUMBERED_CHILD_SUFFIX_RE.test(value.slice(candidate.end));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const RESIDENTIAL_MARKER_TEXT = String.raw`(?:ж\.?\s*к\.?|жил(?:ой|ого)?\s+комплекс\p{L}*|residential\s+complex|turar\s+joy\s+majmuasi|tjm)`;
|
|
152
|
+
const residentialMarkedAliasCache = new WeakMap();
|
|
153
|
+
|
|
154
|
+
function stripResidentialMarker(value) {
|
|
155
|
+
return String(value || '')
|
|
156
|
+
.replace(new RegExp(`^\\s*${RESIDENTIAL_MARKER_TEXT}\\s*[:\\-–—]?\\s*`, 'iu'), '')
|
|
157
|
+
.replace(new RegExp(`\\s*${RESIDENTIAL_MARKER_TEXT}\\s*$`, 'iu'), '')
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function residentialMarkedAliasRegex(item) {
|
|
162
|
+
if (residentialMarkedAliasCache.has(item)) return residentialMarkedAliasCache.get(item);
|
|
163
|
+
const aliases = [...new Set([item?.name, ...(item?.aliases || [])]
|
|
164
|
+
.map(stripResidentialMarker)
|
|
165
|
+
.filter(Boolean))];
|
|
166
|
+
const re = aliases.length ? aliasesToRegex(aliases) : null;
|
|
167
|
+
residentialMarkedAliasCache.set(item, re);
|
|
168
|
+
return re;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function markedResidentialMatch(value, item) {
|
|
172
|
+
const aliasRe = residentialMarkedAliasRegex(item);
|
|
173
|
+
if (!aliasRe) return null;
|
|
174
|
+
const markerRe = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${RESIDENTIAL_MARKER_TEXT}\\s*[:\\-–—]?\\s*`, 'giu');
|
|
175
|
+
for (const marker of value.matchAll(markerRe)) {
|
|
176
|
+
const tailStart = (marker.index ?? 0) + marker[0].length;
|
|
177
|
+
const tail = value.slice(tailStart, Math.min(value.length, tailStart + 96));
|
|
178
|
+
const aliasMatch = tail.match(aliasRe);
|
|
179
|
+
if (!aliasMatch) continue;
|
|
180
|
+
const offset = aliasMatch.index ?? 0;
|
|
181
|
+
if (offset > 0 && /\S/u.test(tail.slice(0, offset))) continue;
|
|
182
|
+
return { 0: aliasMatch[0], index: tailStart + offset };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const LOCATION_CLAUSE_BOUNDARY_RE = /[\n\r.!?;|]/u;
|
|
188
|
+
const PRIMARY_LOCATION_PREFIX_RE = /(?:(?:^|[^\p{L}\p{N}_])(?:адрес|локаци(?:я|и)|расположени(?:е|я)|находится)\s*[:\-–—]?\s*|(?:^|[^\p{L}\p{N}_])(?:в|на)\s+(?:(?:ж\.?\s*к\.?|жил(?:ом|ой)\s+комплекс\p{L}*|микрорайон\p{L}*|мкр\.?|массив\p{L}*|махалл\p{L}*|mahalla|massiv|district|район\p{L}*|metro|метро)\s*)?)$/iu;
|
|
189
|
+
const NEARBY_LOCATION_PREFIX_RE = /(?:^|[^\p{L}\p{N}_])(?:до|около|возле|рядом(?:\s+(?:с|со))?|недалеко\s+от|неподал[её]ку\s+от|вблизи|напротив|ориентир(?:ом)?|near|nearby|close\s+to|next\s+to|opposite|landmark|yaqin(?:ida)?|yonida|mo['’ʻ]?ljal|яқин(?:ида)?|ёнида|мўлжал|жақын|жанында|маңында)(?=$|[^\p{L}\p{N}_])[\s\S]{0,96}$/iu;
|
|
190
|
+
const DISTANCE_LOCATION_PREFIX_RE = /(?:\d+(?:[.,]\d+)?\s*(?:мин(?:ут\p{L}*)?|min(?:ute)?s?|daqiqa|м|m|метр\p{L}*|км|km)\s*(?:от|до|from|to)\s+)[\s\S]{0,80}$/iu;
|
|
191
|
+
const NEARBY_LOCATION_SUFFIX_RE = /^\s*(?:[-—–:]\s*)?(?:(?:\d+(?:[.,]\d+)?\s*(?:мин(?:ут\p{L}*)?|min(?:ute)?s?|daqiqa|м|m|метр\p{L}*|км|km))|(?:рядом|поблизости|nearby|yaqin(?:ida)?|яқин(?:ида)?|жақын))(?=$|[^\p{L}\p{N}_])/iu;
|
|
192
|
+
|
|
193
|
+
function clauseStart(value, start) {
|
|
194
|
+
for (let index = start - 1; index >= 0; index -= 1) {
|
|
195
|
+
if (LOCATION_CLAUSE_BOUNDARY_RE.test(value[index])) return index + 1;
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clauseEnd(value, end) {
|
|
201
|
+
for (let index = end; index < value.length; index += 1) {
|
|
202
|
+
if (LOCATION_CLAUSE_BOUNDARY_RE.test(value[index])) return index;
|
|
203
|
+
}
|
|
204
|
+
return value.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function locationMentionRole(value, candidate) {
|
|
208
|
+
const start = clauseStart(value, candidate.start);
|
|
209
|
+
const end = clauseEnd(value, candidate.end);
|
|
210
|
+
const before = value.slice(start, candidate.start);
|
|
211
|
+
const after = value.slice(candidate.end, end);
|
|
212
|
+
|
|
213
|
+
// A direct subject marker later in the same clause wins over an unrelated
|
|
214
|
+
// earlier temporal "до": "до пятницы квартира в ЖК X" is still ЖК X itself.
|
|
215
|
+
if (PRIMARY_LOCATION_PREFIX_RE.test(before)) return 'primary';
|
|
216
|
+
if (NEARBY_LOCATION_PREFIX_RE.test(before) || DISTANCE_LOCATION_PREFIX_RE.test(before) || NEARBY_LOCATION_SUFFIX_RE.test(after)) {
|
|
217
|
+
return 'nearby';
|
|
218
|
+
}
|
|
219
|
+
return 'mentioned';
|
|
220
|
+
}
|
|
221
|
+
|
|
142
222
|
function publicMatch(candidate) {
|
|
143
223
|
const { start, end, matchedText, explicitContext, ...result } = candidate;
|
|
144
224
|
return Object.freeze(result);
|
|
@@ -149,7 +229,8 @@ function findEntryMatches(text, cityName, data) {
|
|
|
149
229
|
const raw = [];
|
|
150
230
|
for (const key of LOCATION_LIST_KEYS) {
|
|
151
231
|
for (const item of data?.[key] || []) {
|
|
152
|
-
const match = value.match(item?.re)
|
|
232
|
+
const match = value.match(item?.re)
|
|
233
|
+
|| (key === 'residentialComplexes' ? markedResidentialMatch(value, item) : null);
|
|
153
234
|
if (!match) continue;
|
|
154
235
|
const { start, end } = semanticBounds(value, match);
|
|
155
236
|
const candidate = {
|
|
@@ -167,8 +248,11 @@ function findEntryMatches(text, cityName, data) {
|
|
|
167
248
|
end,
|
|
168
249
|
matchedText: value.slice(start, end),
|
|
169
250
|
explicitContext: false,
|
|
251
|
+
role: 'mentioned',
|
|
170
252
|
};
|
|
253
|
+
if (isUmbrellaAreaPrefixOfNumberedBlock(value, candidate)) continue;
|
|
171
254
|
candidate.explicitContext = hasExplicitContext(value, candidate);
|
|
255
|
+
candidate.role = locationMentionRole(value, candidate);
|
|
172
256
|
raw.push(candidate);
|
|
173
257
|
}
|
|
174
258
|
}
|
|
@@ -274,4 +358,4 @@ export function matchCentralAsiaLocationEntities(text, countryCode, preferredCit
|
|
|
274
358
|
export function matchCentralAsiaLocationEntity(text, countryCode, preferredCity = null, type = null) {
|
|
275
359
|
const result = matchCentralAsiaLocationEntities(text, countryCode, preferredCity);
|
|
276
360
|
return result.matches.find((item) => !type || item.type === type || item.key === type) || null;
|
|
277
|
-
}
|
|
361
|
+
}
|
|
@@ -39,6 +39,7 @@ const PLACE_KINDS = Object.freeze([
|
|
|
39
39
|
const NEARBY_BLOCK_RE = /(?:^|\n)[^\n]{0,30}?(?:что\s+(?:есть|находится)\s+р[яa]дом|р[яa]дом(?:\s+(?:есть|находятся|расположены|с\s+домом))?|непода[лл][её]ку|поблизости|в\s+шаговой\s+доступности|окружени[ие]|ориентир(?:ы|ами)?|yaqinida|atrofida|yonida|nearby|landmarks?)\s*[:—-]?\s*([^\n]{4,500})/iu;
|
|
40
40
|
const NEARBY_NOISE_RE = /^(?:и|а|в|на|у|до|от|все|вс[её]|есть|рядом|близко|недалеко|минут\p{L}*|пешком|транспорт|остановк\p{L}*|магазин|магазины|аптека|аптеки|садик|садики|всё\s+необходимое|развит\p{L}*)$/iu;
|
|
41
41
|
const NEARBY_FILLER_RE = /(?:инфраструктур\p{L}*|все\s+необходимо|вс[её]\s+рядом|шаговой\s+доступност|развитый\s+район)/iu;
|
|
42
|
+
const QUARTER_NEARBY_PREFIX_RE = /(?:^|[\s,;:—–-])(?:до|рядом\s+(?:с|со)|недалеко\s+от|возле|около|ориентир\s*[:—–-]?|near(?:by)?|close\s+to|lângă|aproape\s+de)\s*$/iu;
|
|
42
43
|
|
|
43
44
|
function enumeratedNearby(text) {
|
|
44
45
|
const blocks = [...text.matchAll(new RegExp(NEARBY_BLOCK_RE.source, `${NEARBY_BLOCK_RE.flags}g`))]
|
|
@@ -61,18 +62,33 @@ function enumeratedNearby(text) {
|
|
|
61
62
|
return items;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function isNearbyQuarterReference(text, match) {
|
|
66
|
+
const start = match?.index ?? -1;
|
|
67
|
+
if (start < 0) return false;
|
|
68
|
+
const before = text.slice(Math.max(0, start - 64), start);
|
|
69
|
+
return QUARTER_NEARBY_PREFIX_RE.test(before);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function firstPrimaryQuarterMatch(text, pattern) {
|
|
73
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
74
|
+
for (const match of text.matchAll(new RegExp(pattern.source, flags))) {
|
|
75
|
+
if (!isNearbyQuarterReference(text, match)) return match;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
export function parseHousingQuarterLabel(value) {
|
|
65
81
|
const text = String(value || '');
|
|
66
82
|
if (!text) return null;
|
|
67
83
|
if (/(?:^|[^\p{L}\p{N}_])(?:глинк[аи]?|glinka)(?:$|[^\p{L}\p{N}_])/iu.test(text)) return 'Glinka';
|
|
68
|
-
const match = text
|
|
69
|
-
|| text
|
|
70
|
-
|| text
|
|
84
|
+
const match = firstPrimaryQuarterMatch(text, /(\d{1,3})\s*(?:-?\s*(?:chi|чи))?\s*[-\s]?\s*(?:квартал|кв-?л(?=$|[^\p{L}\p{N}_])|kvartal(?:i)?|квартил|kvartil|мкр(?=$|[^\p{L}\p{N}_])|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)/iu)
|
|
85
|
+
|| firstPrimaryQuarterMatch(text, /(?:квартал|kvartal(?:i)?|квартил|kvartil|мкр|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)\s*[-№#]?\s*(\d{1,3})/iu)
|
|
86
|
+
|| firstPrimaryQuarterMatch(text, /(?:чиланзар|chilonzor|chilanzar)\s*[-№#]?\s*(\d{1,2})(?!\d)/iu);
|
|
71
87
|
if (match) {
|
|
72
88
|
const label = `${Number(match[1])} kvartal`;
|
|
73
89
|
return STREET_LIKE_PLACE_RE.test(label) ? null : label;
|
|
74
90
|
}
|
|
75
|
-
const centralBlock = text
|
|
91
|
+
const centralBlock = firstPrimaryQuarterMatch(text, /(?:^|[^\p{L}\p{N}_])(?:ц|c)\s*[-–]?\s*(\d{1,2})(?:$|[^\p{L}\p{N}_])/iu);
|
|
76
92
|
return centralBlock ? `C-${Number(centralBlock[1])}` : null;
|
|
77
93
|
}
|
|
78
94
|
|
|
@@ -3,6 +3,7 @@ import { findAllCanonical, normalizeUnicode } from './normalization.js';
|
|
|
3
3
|
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
4
4
|
import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
|
|
5
5
|
import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
|
|
6
|
+
import { TASHKENT_RESIDENTIAL_COMPLEXES, matchTashkentResidentialComplex } from './tashkent-residential-complexes.js';
|
|
6
7
|
import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
|
|
7
8
|
import { parseHousingListingFields } from './housing-listing-fields.js';
|
|
8
9
|
import { parseHousingResidentialComplex } from './housing-text.js';
|
|
@@ -38,6 +39,9 @@ const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(
|
|
|
38
39
|
const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
|
|
39
40
|
const WALK_MINUTES_RE = /(?:yayov|piyoda|пешком)\s*(\d{1,2})\s*(?:daqiqa|min(?:ute)?s?|минут)/iu;
|
|
40
41
|
const TRANSIT_ROUTES_RE = /(?:aftobuslar|avtobuslar|автобуслар|автобусы)[^\r\n\d]{0,24}((?:\d{1,4}[\s,;/]*){1,10})/iu;
|
|
42
|
+
const NEARBY_RELATION_TAIL_RE = /(?<!\p{L})(?:рядом\s+(?:с|со)|недалеко\s+от|возле|около|ориентир\s*[:—–-]?|near(?:by)?|close\s+to|lângă|aproape\s+de)(?!\p{L})[^.!?\r\n;]*/giu;
|
|
43
|
+
const NEARBY_TRAVEL_TAIL_RE = /(?<!\p{L})(?:до|până\s+la)(?!\p{L})[^.!?\r\n;]{0,96}(?<!\p{L})\d{1,3}\s*(?:мин(?:ут(?:ы|а|ах)?|\.?)?|min(?:ute)?s?|дақиқ\p{L}*|daqiqa|км|km|метр(?:а|ов)?|m)(?!\p{L})[^.!?\r\n;]*/giu;
|
|
44
|
+
const RESIDENTIAL_CONTEXT_RE = /(?:ж\.?\s*к\.?|жил(?:ой|ого)\s+комплекс|новострой(?:ка|ки)?|residential\s+complex|residence|turar\s+joy|uy[-\s]?joy|majmua|массив)/iu;
|
|
41
45
|
|
|
42
46
|
function unique(values) {
|
|
43
47
|
return [...new Set(values.filter(Boolean))];
|
|
@@ -47,6 +51,42 @@ function categoryOf(entry) {
|
|
|
47
51
|
return entry?.category || GENERIC_CATEGORY[entry?.canonical] || 'landmark';
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
function nearbyReferenceRanges(text) {
|
|
55
|
+
const ranges = [];
|
|
56
|
+
for (const pattern of [NEARBY_RELATION_TAIL_RE, NEARBY_TRAVEL_TAIL_RE]) {
|
|
57
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
58
|
+
for (const match of text.matchAll(regex)) {
|
|
59
|
+
const start = match.index ?? 0;
|
|
60
|
+
ranges.push({ start, end: start + match[0].length });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ranges;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function insideNearbyReference(match, ranges) {
|
|
67
|
+
return ranges.some((range) => match.start >= range.start && match.end <= range.end);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withoutNearbyLocationReferences(text) {
|
|
71
|
+
return String(text || '')
|
|
72
|
+
.replace(NEARBY_RELATION_TAIL_RE, ' ')
|
|
73
|
+
.replace(NEARBY_TRAVEL_TAIL_RE, ' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasResidentialContext(text, match) {
|
|
77
|
+
if (!match.entry?.ambiguous) return true;
|
|
78
|
+
const context = text.slice(Math.max(0, match.start - 36), Math.min(text.length, match.end + 36));
|
|
79
|
+
return RESIDENTIAL_CONTEXT_RE.test(context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function residentialComplexNearbyMatches(text) {
|
|
83
|
+
const ranges = nearbyReferenceRanges(text);
|
|
84
|
+
if (!ranges.length) return [];
|
|
85
|
+
return findAllCanonical(text, TASHKENT_RESIDENTIAL_COMPLEXES)
|
|
86
|
+
.filter((match) => insideNearbyReference(match, ranges) && hasResidentialContext(text, match))
|
|
87
|
+
.map((match) => ({ canonical: match.canonical, category: 'residential_complex', start: match.start }));
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
function extensionMatches(text) {
|
|
51
91
|
return findAllCanonical(text, HOUSING_POI_EXTENSIONS).map((match) => ({
|
|
52
92
|
canonical: match.canonical,
|
|
@@ -83,7 +123,7 @@ function genericMatches(text) {
|
|
|
83
123
|
export function parseHousingNearby(value) {
|
|
84
124
|
const text = normalizeUnicode(value ?? '');
|
|
85
125
|
if (!text) return deepFreeze([]);
|
|
86
|
-
const specific = [...extensionMatches(text), ...catalogPoiMatches(text)].sort((a, b) => a.start - b.start);
|
|
126
|
+
const specific = [...extensionMatches(text), ...catalogPoiMatches(text), ...residentialComplexNearbyMatches(text)].sort((a, b) => a.start - b.start);
|
|
87
127
|
const specificCategories = new Set(specific.map((item) => item.category));
|
|
88
128
|
const generic = genericMatches(text)
|
|
89
129
|
.filter((item) => !specificCategories.has(item.category))
|
|
@@ -201,7 +241,10 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
201
241
|
const quarter = matchTashkentHousingQuarter(text);
|
|
202
242
|
const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
|
|
203
243
|
const metro = matchTashkentHousingMetro(text)?.name || null;
|
|
204
|
-
const
|
|
244
|
+
const primaryResidentialText = withoutNearbyLocationReferences(text);
|
|
245
|
+
const parsedRc = specificResidentialComplex(primaryResidentialText)
|
|
246
|
+
|| matchTashkentResidentialComplex(primaryResidentialText)?.name
|
|
247
|
+
|| parseHousingResidentialComplex(primaryResidentialText);
|
|
205
248
|
const commission = parseHousingCommission(text);
|
|
206
249
|
const commissionAmount = parseHousingCommissionAmount(text);
|
|
207
250
|
const address = parseHousingAddress(text);
|
|
@@ -108,6 +108,12 @@ export const HOUSING_POI_EXTENSIONS = Object.freeze([
|
|
|
108
108
|
uzLatn: ['Assalom Jomiy', 'Assalom Jomiy turar joy majmuasi'],
|
|
109
109
|
uzCyrl: ['Ассалом Жомий'],
|
|
110
110
|
}, { category: 'residential_complex', display: { ru: 'ЖК «Ассалом Жомий»', en: 'Assalom Jomiy' } }),
|
|
111
|
+
group('Assalom Sohil', {
|
|
112
|
+
ru: ['Ассалом Сохил', 'ЖК Ассалом Сохил', 'жилой комплекс Ассалом Сохил'],
|
|
113
|
+
en: ['Assalom Sohil', 'Assalom Sohil residential complex'],
|
|
114
|
+
uzLatn: ['Assalom Sohil', 'Assalom Sohil turar joy majmuasi'],
|
|
115
|
+
uzCyrl: ['Ассалом Сохил'],
|
|
116
|
+
}, { category: 'residential_complex', display: { ru: 'ЖК «Ассалом Сохил»', en: 'Assalom Sohil' } }),
|
|
111
117
|
group('Jomiy Roundabout', {
|
|
112
118
|
ru: ['круг Жомий', 'круг жомий', 'кольцо Жомий'],
|
|
113
119
|
en: ['Jomiy Roundabout', 'Jomiy circle'],
|
|
@@ -84,6 +84,23 @@ export function parseHousingFloor(value) {
|
|
|
84
84
|
const text = normalizeUnicode(value ?? '');
|
|
85
85
|
if (!text) return deepFreeze({ floor: null, totalFloors: null });
|
|
86
86
|
|
|
87
|
+
// Common classifieds shorthand is rooms/floor/total floors, e.g. 2/10/16.
|
|
88
|
+
// It must be checked before the generic floor/total fraction parser or the
|
|
89
|
+
// first two numbers would incorrectly become floor=2, totalFloors=10.
|
|
90
|
+
// Requiring the triple to occupy its own line avoids treating dates as this
|
|
91
|
+
// housing-specific shorthand inside ordinary prose.
|
|
92
|
+
const compactTriple = text.match(/(?:^|[\r\n])\s*(\d{1,2})\s*[\/\\]\s*(\d{1,3})\s*[\/\\]\s*(\d{1,3})\s*(?=$|[\r\n])/u);
|
|
93
|
+
if (compactTriple) {
|
|
94
|
+
const rooms = toNumber(compactTriple[1]);
|
|
95
|
+
const floor = toNumber(compactTriple[2]);
|
|
96
|
+
const totalFloors = toNumber(compactTriple[3]);
|
|
97
|
+
if (rooms != null && rooms >= 1 && rooms <= 20
|
|
98
|
+
&& floor != null && totalFloors != null
|
|
99
|
+
&& floor >= 1 && floor <= totalFloors && totalFloors <= 200) {
|
|
100
|
+
return deepFreeze({ floor, totalFloors });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*[\/\\]\s*(\d{1,3})(?=$|[^\d])/u);
|
|
88
105
|
if (fraction) {
|
|
89
106
|
const floor = toNumber(fraction[1]);
|
|
@@ -142,7 +159,7 @@ const AREA_LABELS = Object.freeze([
|
|
|
142
159
|
['total', /(?:общая\s+площадь|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
|
|
143
160
|
]);
|
|
144
161
|
|
|
145
|
-
const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s
|
|
162
|
+
const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s*м|кв2|квадрат\p{L}*)`;
|
|
146
163
|
|
|
147
164
|
function areaAfterLabel(text, labelRe) {
|
|
148
165
|
const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_RE}`, 'iu');
|
package/src/locations-runtime.js
CHANGED
|
@@ -8,11 +8,16 @@ import {
|
|
|
8
8
|
import { LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
|
|
9
9
|
import { KG_LOCATION_EXTENSIONS } from './kg-location-extensions.js';
|
|
10
10
|
import { KZ_DISTRICT_EXTENSIONS } from './kz-district-extensions.js';
|
|
11
|
+
import { UZ_TASHKENT_CONTEXT_EXTENSIONS } from './uz-tashkent-context-extensions.js';
|
|
11
12
|
|
|
12
13
|
export { UA_REGION_ENTRIES, UA_SECONDARY_CITIES, matchUkraineRegion, matchUkraineSecondaryCity };
|
|
13
14
|
|
|
14
15
|
export const LOCATION_DICTIONARIES = Object.freeze({
|
|
15
16
|
...BASE_LOCATION_DICTIONARIES,
|
|
17
|
+
UZ: mergeLocationCountries(
|
|
18
|
+
BASE_LOCATION_DICTIONARIES.UZ || {},
|
|
19
|
+
UZ_TASHKENT_CONTEXT_EXTENSIONS,
|
|
20
|
+
),
|
|
16
21
|
KZ: mergeLocationCountries(
|
|
17
22
|
BASE_LOCATION_DICTIONARIES.KZ || {},
|
|
18
23
|
KZ_DISTRICT_EXTENSIONS,
|
|
@@ -152,6 +152,14 @@ export const UA_CITY_LOCATION_EXPANSIONS = Object.freeze({
|
|
|
152
152
|
['Nizhyn Railway Station', 'Залізничний вокзал Ніжина', 'станція Ніжин'],
|
|
153
153
|
['Ніжинський краєзнавчий музей імені Івана Спаського', 'Ніжинський краєзнавчий музей ім. І. Спаського', 'Ніжинський краєзнавчий музей ім. Спаського І.Г.'],
|
|
154
154
|
['Ніжинська поштова станція', 'Музей «Ніжинська поштова станція»', 'Ніжинський музей "Поштова станція"'],
|
|
155
|
+
['Ніжинський державний університет імені Миколи Гоголя', 'НДУ ім. М. Гоголя', 'Nizhyn Mykola Gogol State University'],
|
|
156
|
+
['Свято-Миколаївський кафедральний собор', 'Миколаївський кафедральний собор (Ніжин)', 'Миколаївський собор', 'Собор Святого Миколая Чудотворця'],
|
|
157
|
+
]),
|
|
158
|
+
|
|
159
|
+
Pryluky: landmarks([
|
|
160
|
+
['Pryluky Railway Station'],
|
|
161
|
+
['Прилуцький краєзнавчий музей імені В. І. Маслова', 'Прилуцький краєзнавчий музей ім. В. І. Маслова', 'Прилуцький краєзнавчий музей ім. В.І. Маслова'],
|
|
162
|
+
['Собор Різдва Пресвятої Богородиці', 'Собор Різдва Богородиці', 'Собор Різдва Богородиці (Прилуки)'],
|
|
155
163
|
]),
|
|
156
164
|
|
|
157
165
|
Volodymyr: landmarks([
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TASHKENT_AREAS } from './geo.js';
|
|
2
|
+
import { aliasesToRegex } from './normalization.js';
|
|
3
|
+
|
|
4
|
+
function legacyAreaEntry(district, canonical) {
|
|
5
|
+
const source = (TASHKENT_AREAS[district] || []).find((item) => item.name === canonical);
|
|
6
|
+
if (!source) throw new Error(`Missing canonical Tashkent area: ${district}/${canonical}`);
|
|
7
|
+
|
|
8
|
+
const aliases = Object.freeze([...new Set([
|
|
9
|
+
source.name,
|
|
10
|
+
...(source.aliases || []),
|
|
11
|
+
].filter(Boolean))]);
|
|
12
|
+
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
canonical: source.name,
|
|
15
|
+
name: source.name,
|
|
16
|
+
type: source.type || 'local_area',
|
|
17
|
+
entityType: source.type || 'local_area',
|
|
18
|
+
country: 'UZ',
|
|
19
|
+
city: 'Tashkent',
|
|
20
|
+
district,
|
|
21
|
+
parent: district,
|
|
22
|
+
aliases,
|
|
23
|
+
re: aliasesToRegex(aliases),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// C-1 / Ц-1 is a very common Tashkent housing reference. The historical
|
|
28
|
+
// TASHKENT_AREAS catalog already owns its canonical identity (`Buyuk Ipak Yuli`),
|
|
29
|
+
// so expose that same entity to the runtime location matcher instead of creating
|
|
30
|
+
// a second C-1 canonical or consumer-local regex.
|
|
31
|
+
const C1 = legacyAreaEntry('Mirzo Ulugbek', 'Buyuk Ipak Yuli');
|
|
32
|
+
|
|
33
|
+
export const UZ_TASHKENT_CONTEXT_EXTENSIONS = Object.freeze({
|
|
34
|
+
Tashkent: Object.freeze({
|
|
35
|
+
localAreas: Object.freeze([C1]),
|
|
36
|
+
}),
|
|
37
|
+
});
|