@whiteslove/parsing-lexicon 0.2.14 → 0.3.2

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/index.d.ts CHANGED
@@ -80,7 +80,7 @@ export type SearchCluster = Readonly<{
80
80
  export type AreaEntry = Readonly<{
81
81
  canonical: string;
82
82
  name: string;
83
- type: 'local_area';
83
+ type: 'local_area' | 'microdistrict';
84
84
  country: 'UZ';
85
85
  city: 'Tashkent';
86
86
  aliases: readonly string[];
@@ -92,6 +92,7 @@ export type LocationCityDictionary = Readonly<{
92
92
  localAreas?: readonly LocationEntry[];
93
93
  suburbs?: readonly LocationEntry[];
94
94
  settlements?: readonly LocationEntry[];
95
+ developmentAreas?: readonly LocationEntry[];
95
96
  metro?: readonly LocationEntry[] | readonly MetroStation[];
96
97
  residentialComplexes?: readonly LocationEntry[];
97
98
  streets?: readonly LocationEntry[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.2.14",
3
+ "version": "0.3.2",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for Whiteslove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,15 +10,27 @@
10
10
  "main": "./src/index.js",
11
11
  "types": "./index.d.ts",
12
12
  "exports": {
13
- ".": { "types": "./index.d.ts", "import": "./src/index.js" },
13
+ ".": {
14
+ "types": "./index.d.ts",
15
+ "import": "./src/index.js"
16
+ },
14
17
  "./lexicon-core": "./src/lexicon-core.js",
15
18
  "./normalization": "./src/normalization.js",
16
19
  "./countries": "./src/countries.js",
17
- "./country-context": { "types": "./src/country-context.d.ts", "import": "./src/country-context.js" },
20
+ "./country-context": {
21
+ "types": "./src/country-context.d.ts",
22
+ "import": "./src/country-context.js"
23
+ },
18
24
  "./geo": "./src/geo.js",
19
25
  "./geography": "./src/geography.js",
20
- "./geography-display": { "types": "./src/geography-display.d.ts", "import": "./src/geography-display.js" },
21
- "./geography-detection": { "types": "./src/geography-detection.d.ts", "import": "./src/geography-detection.js" },
26
+ "./geography-display": {
27
+ "types": "./src/geography-display.d.ts",
28
+ "import": "./src/geography-display.js"
29
+ },
30
+ "./geography-detection": {
31
+ "types": "./src/geography-detection.d.ts",
32
+ "import": "./src/geography-detection.js"
33
+ },
22
34
  "./central-asia": "./src/central-asia.js",
23
35
  "./ukraine": "./src/ukraine.js",
24
36
  "./location-merge": "./src/location-merge.js",
@@ -33,54 +45,170 @@
33
45
  "./tashkent-colloquial": "./src/tashkent-colloquial.js",
34
46
  "./tashkent-residential-complexes": "./src/tashkent-residential-complexes.js",
35
47
  "./tashkent-pois": "./src/tashkent-pois.js",
36
- "./tashkent-housing-geography": { "types": "./src/tashkent-housing-geography.d.ts", "import": "./src/tashkent-housing-geography.js" },
48
+ "./tashkent-housing-geography": {
49
+ "types": "./src/tashkent-housing-geography.d.ts",
50
+ "import": "./src/tashkent-housing-geography.js"
51
+ },
37
52
  "./odesa-metropolitan": "./src/odesa-metropolitan.js",
38
53
  "./housing": "./src/housing.js",
39
- "./housing-intent": { "types": "./src/housing-intent.d.ts", "import": "./src/housing-intent.js" },
40
- "./housing-commercial": { "types": "./src/housing-commercial.d.ts", "import": "./src/housing-commercial.js" },
41
- "./housing-context": { "types": "./src/housing-context.d.ts", "import": "./src/housing-context.js" },
42
- "./housing-address": { "types": "./src/housing-address.d.ts", "import": "./src/housing-address.js" },
43
- "./housing-structured": { "types": "./src/housing-structured.d.ts", "import": "./src/housing-structured.js" },
44
- "./housing-safety": { "types": "./src/housing-safety.d.ts", "import": "./src/housing-safety.js" },
45
- "./housing-title": { "types": "./src/housing-title.d.ts", "import": "./src/housing-title.js" },
46
- "./housing-language": { "types": "./src/housing-language.d.ts", "import": "./src/housing-language.js" },
47
- "./housing-features": { "types": "./src/housing-features.d.ts", "import": "./src/housing-features.js" },
48
- "./housing-listing-fields": { "types": "./src/housing-listing-fields.d.ts", "import": "./src/housing-listing-fields.js" },
49
- "./housing-text": { "types": "./src/housing-text.d.ts", "import": "./src/housing-text.js" },
50
- "./housing-source-aliases": { "types": "./src/housing-source-aliases.d.ts", "import": "./src/housing-source-aliases.js" },
54
+ "./housing-intent": {
55
+ "types": "./src/housing-intent.d.ts",
56
+ "import": "./src/housing-intent.js"
57
+ },
58
+ "./housing-commercial": {
59
+ "types": "./src/housing-commercial.d.ts",
60
+ "import": "./src/housing-commercial.js"
61
+ },
62
+ "./housing-context": {
63
+ "types": "./src/housing-context.d.ts",
64
+ "import": "./src/housing-context.js"
65
+ },
66
+ "./housing-address": {
67
+ "types": "./src/housing-address.d.ts",
68
+ "import": "./src/housing-address.js"
69
+ },
70
+ "./housing-structured": {
71
+ "types": "./src/housing-structured.d.ts",
72
+ "import": "./src/housing-structured.js"
73
+ },
74
+ "./housing-safety": {
75
+ "types": "./src/housing-safety.d.ts",
76
+ "import": "./src/housing-safety.js"
77
+ },
78
+ "./housing-title": {
79
+ "types": "./src/housing-title.d.ts",
80
+ "import": "./src/housing-title.js"
81
+ },
82
+ "./housing-language": {
83
+ "types": "./src/housing-language.d.ts",
84
+ "import": "./src/housing-language.js"
85
+ },
86
+ "./housing-features": {
87
+ "types": "./src/housing-features.d.ts",
88
+ "import": "./src/housing-features.js"
89
+ },
90
+ "./housing-listing-fields": {
91
+ "types": "./src/housing-listing-fields.d.ts",
92
+ "import": "./src/housing-listing-fields.js"
93
+ },
94
+ "./housing-text": {
95
+ "types": "./src/housing-text.d.ts",
96
+ "import": "./src/housing-text.js"
97
+ },
98
+ "./housing-source-aliases": {
99
+ "types": "./src/housing-source-aliases.d.ts",
100
+ "import": "./src/housing-source-aliases.js"
101
+ },
51
102
  "./housing-poi-extensions": "./src/housing-poi-extensions.js",
52
- "./housing-display": { "types": "./src/housing-display.d.ts", "import": "./src/housing-display.js" },
53
- "./housing-listing-enrichment": { "types": "./src/housing-listing-enrichment.d.ts", "import": "./src/housing-listing-enrichment.js" },
103
+ "./housing-display": {
104
+ "types": "./src/housing-display.d.ts",
105
+ "import": "./src/housing-display.js"
106
+ },
107
+ "./housing-listing-enrichment": {
108
+ "types": "./src/housing-listing-enrichment.d.ts",
109
+ "import": "./src/housing-listing-enrichment.js"
110
+ },
54
111
  "./hiring": "./src/hiring.js",
55
112
  "./hiring-languages": "./src/hiring-languages.js",
56
- "./hiring-language-extensions": { "types": "./src/hiring-language-extensions.d.ts", "import": "./src/hiring-language-extensions.js" },
57
- "./hiring-context": { "types": "./src/hiring-context.d.ts", "import": "./src/hiring-context.js" },
58
- "./hiring-source-aliases": { "types": "./src/hiring-source-aliases.d.ts", "import": "./src/hiring-source-aliases.js" },
59
- "./hiring-source-semantics": { "types": "./src/hiring-source-semantics.d.ts", "import": "./src/hiring-source-semantics.js" },
60
- "./hiring-requirements": { "types": "./src/hiring-requirements.d.ts", "import": "./src/hiring-requirements.js" },
61
- "./hiring-semantics": { "types": "./src/hiring-semantics.d.ts", "import": "./src/hiring-semantics.js" },
62
- "./hiring-work-semantics": { "types": "./src/hiring-work-semantics.d.ts", "import": "./src/hiring-work-semantics.js" },
63
- "./hiring-candidate-fields": { "types": "./src/hiring-candidate-fields.d.ts", "import": "./src/hiring-candidate-fields.js" },
64
- "./hiring-temporal": { "types": "./src/hiring-temporal.d.ts", "import": "./src/hiring-temporal.js" },
65
- "./hiring-vacancy-fields": { "types": "./src/hiring-vacancy-fields.d.ts", "import": "./src/hiring-vacancy-fields.js" },
66
- "./hiring-location-fields": { "types": "./src/hiring-location-fields.d.ts", "import": "./src/hiring-location-fields.js" },
67
- "./hiring-salary-context": { "types": "./src/hiring-salary-context.d.ts", "import": "./src/hiring-salary-context.js" },
113
+ "./hiring-language-extensions": {
114
+ "types": "./src/hiring-language-extensions.d.ts",
115
+ "import": "./src/hiring-language-extensions.js"
116
+ },
117
+ "./hiring-context": {
118
+ "types": "./src/hiring-context.d.ts",
119
+ "import": "./src/hiring-context.js"
120
+ },
121
+ "./hiring-source-aliases": {
122
+ "types": "./src/hiring-source-aliases.d.ts",
123
+ "import": "./src/hiring-source-aliases.js"
124
+ },
125
+ "./hiring-source-semantics": {
126
+ "types": "./src/hiring-source-semantics.d.ts",
127
+ "import": "./src/hiring-source-semantics.js"
128
+ },
129
+ "./hiring-requirements": {
130
+ "types": "./src/hiring-requirements.d.ts",
131
+ "import": "./src/hiring-requirements.js"
132
+ },
133
+ "./hiring-semantics": {
134
+ "types": "./src/hiring-semantics.d.ts",
135
+ "import": "./src/hiring-semantics.js"
136
+ },
137
+ "./hiring-work-semantics": {
138
+ "types": "./src/hiring-work-semantics.d.ts",
139
+ "import": "./src/hiring-work-semantics.js"
140
+ },
141
+ "./hiring-candidate-fields": {
142
+ "types": "./src/hiring-candidate-fields.d.ts",
143
+ "import": "./src/hiring-candidate-fields.js"
144
+ },
145
+ "./hiring-temporal": {
146
+ "types": "./src/hiring-temporal.d.ts",
147
+ "import": "./src/hiring-temporal.js"
148
+ },
149
+ "./hiring-vacancy-fields": {
150
+ "types": "./src/hiring-vacancy-fields.d.ts",
151
+ "import": "./src/hiring-vacancy-fields.js"
152
+ },
153
+ "./hiring-location-fields": {
154
+ "types": "./src/hiring-location-fields.d.ts",
155
+ "import": "./src/hiring-location-fields.js"
156
+ },
157
+ "./hiring-salary-context": {
158
+ "types": "./src/hiring-salary-context.d.ts",
159
+ "import": "./src/hiring-salary-context.js"
160
+ },
68
161
  "./money": "./src/money.js",
69
- "./currency": { "types": "./src/currency.d.ts", "import": "./src/currency.js" },
70
- "./contact": { "types": "./src/contact.d.ts", "import": "./src/contact.js" },
71
- "./housing-money": { "types": "./src/housing-money.d.ts", "import": "./src/housing-money.js" },
162
+ "./currency": {
163
+ "types": "./src/currency.d.ts",
164
+ "import": "./src/currency.js"
165
+ },
166
+ "./contact": {
167
+ "types": "./src/contact.d.ts",
168
+ "import": "./src/contact.js"
169
+ },
170
+ "./housing-money": {
171
+ "types": "./src/housing-money.d.ts",
172
+ "import": "./src/housing-money.js"
173
+ },
72
174
  "./hiring-professions": "./src/hiring-professions.js",
73
- "./hiring-skills": { "types": "./src/hiring-skills.d.ts", "import": "./src/hiring-skills.js" },
175
+ "./hiring-skills": {
176
+ "types": "./src/hiring-skills.d.ts",
177
+ "import": "./src/hiring-skills.js"
178
+ },
74
179
  "./hiring-advanced": "./src/hiring-advanced.js"
75
180
  },
76
- "files": ["src", "index.d.ts", "README.md"],
181
+ "files": [
182
+ "src",
183
+ "index.d.ts",
184
+ "README.md"
185
+ ],
77
186
  "sideEffects": false,
78
- "engines": { "node": ">=20" },
79
- "scripts": { "test": "node --test" },
80
- "publishConfig": { "access": "public" },
187
+ "engines": {
188
+ "node": ">=20"
189
+ },
190
+ "scripts": {
191
+ "test": "node --test"
192
+ },
193
+ "publishConfig": {
194
+ "access": "public"
195
+ },
81
196
  "dependencies": {
82
197
  "libphonenumber-js": "^1.11.20"
83
198
  },
84
- "keywords": ["parsing", "lexicon", "uzbek", "kazakh", "ukrainian", "romanian", "russian", "housing", "hiring", "salary", "professions", "geography"],
199
+ "keywords": [
200
+ "parsing",
201
+ "lexicon",
202
+ "uzbek",
203
+ "kazakh",
204
+ "ukrainian",
205
+ "romanian",
206
+ "russian",
207
+ "housing",
208
+ "hiring",
209
+ "salary",
210
+ "professions",
211
+ "geography"
212
+ ],
85
213
  "license": "MIT"
86
214
  }
@@ -22,6 +22,7 @@ const TYPE_BY_KEY = Object.freeze({
22
22
  localAreas: 'local_area',
23
23
  suburbs: 'suburb',
24
24
  settlements: 'settlement',
25
+ developmentAreas: 'development_area',
25
26
  metro: 'metro',
26
27
  residentialComplexes: 'residential_complex',
27
28
  streets: 'street',
@@ -73,31 +74,85 @@ function explicitCityFromText(text, countryCode) {
73
74
  return matches[0]?.item?.canonical || null;
74
75
  }
75
76
 
76
- function isExplicitMetroContext(value, match) {
77
- const start = match.index ?? 0;
78
- const end = start + match[0].length;
79
- const before = value.slice(Math.max(0, start - 36), start);
80
- const after = value.slice(end, end + 36);
81
- return /(?:метро|metro|станц(?:ия|ии)?|station|ст\.?\s*м\.?|м\.)\s*[:\-–—]?\s*$/iu.test(before)
82
- || /^\s*(?:метро|metro|station|станц(?:ия|ии)?)(?=$|[^\p{L}\p{N}_])/iu.test(after);
77
+ const CONTEXT_PATTERNS = Object.freeze({
78
+ metro: Object.freeze({
79
+ before: /(?:метро|metro|станц(?:ия|ии)?|station|ст\.?\s*м\.?)\s*[:\-–—]?\s*$/iu,
80
+ after: /^\s*[:\-–—]?\s*(?:метро|metro|станц(?:ия|ии)?|station)(?=$|[^\p{L}\p{N}_])/iu,
81
+ inside: /(?:метро|metro|станц(?:ия|ии)?|station)/iu,
82
+ }),
83
+ district: Object.freeze({
84
+ before: /(?:район|р-н|рн|туман\p{L}{0,4}|tumani|district)\s*[:\-–—]?\s*$/iu,
85
+ after: /^\s*[:\-–—]?\s*(?:район|р-н|рн|туман\p{L}{0,4}|tumani|district)(?=$|[^\p{L}\p{N}_])/iu,
86
+ }),
87
+ mahalla: Object.freeze({
88
+ before: /(?:махалл(?:а|я)|маҳалла(?:си)?|mahalla(?:si)?|mfy|мфй)\s*[:\-–—]?\s*$/iu,
89
+ after: /^\s*[:\-–—]?\s*(?:махалл(?:а|я)|маҳалла(?:си)?|mahalla(?:si)?|mfy|мфй)(?=$|[^\p{L}\p{N}_])/iu,
90
+ }),
91
+ microdistrict: Object.freeze({
92
+ before: /(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа)\s*[:\-–—]?\s*$/iu,
93
+ after: /^\s*(?:\d{1,2}[aа]?\s*)?[:\-–—]?\s*(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа)(?=$|[^\p{L}\p{N}_])/iu,
94
+ }),
95
+ local_area: Object.freeze({
96
+ before: /(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа|зона|area)\s*[:\-–—]?\s*$/iu,
97
+ after: /^\s*(?:\d{1,2}[aа]?\s*)?[:\-–—]?\s*(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа|зона|area)(?=$|[^\p{L}\p{N}_])/iu,
98
+ }),
99
+ development_area: Object.freeze({
100
+ before: /(?:(?:международн\p{L}*|international)\s+)?(?:бизнес[-\s]?центр|делов\p{L}*\s+центр|business\s+cent(?:er|re)|development\s+area|mixed[-\s]?use\s+(?:district|development))\s*[:\-–—]?\s*$/iu,
101
+ after: /^\s*[:\-–—]?\s*(?:(?:международн\p{L}*|international)\s+)?(?:бизнес[-\s]?центр|делов\p{L}*\s+центр|business\s+cent(?:er|re)|development\s+area|mixed[-\s]?use\s+(?:district|development))(?=$|[^\p{L}\p{N}_])/iu,
102
+ }),
103
+ residential_complex: Object.freeze({
104
+ before: /(?:жк|жилой\s+комплекс|residential\s+complex|residence|turar\s+joy\s+majmuasi|tjm)\s*[:\-–—]?\s*$/iu,
105
+ after: /^\s*[:\-–—]?\s*(?:жк|жилой\s+комплекс|residential\s+complex|residence|turar\s+joy\s+majmuasi|tjm)(?=$|[^\p{L}\p{N}_])/iu,
106
+ }),
107
+ street: Object.freeze({
108
+ before: /(?:улица|ул\.?|street|st\.?|ko['’ʻ]?cha(?:si)?|кўча(?:си)?)\s*[:\-–—]?\s*$/iu,
109
+ after: /^\s*[:\-–—]?\s*(?:улица|ул\.?|street|st\.?|ko['’ʻ]?cha(?:si)?|кўча(?:си)?)(?=$|[^\p{L}\p{N}_])/iu,
110
+ }),
111
+ poi: Object.freeze({
112
+ before: /(?:базар|рынок|bozor(?:i)?|мечет\p{L}*|масжид|masjid|mosque|парк|park|mall|молл|трц|тц|вокзал|аэропорт|airport|университет|university)\s*[:\-–—]?\s*$/iu,
113
+ after: /^\s*[:\-–—]?\s*(?:базар|рынок|bozor(?:i)?|мечет\p{L}*|масжид|masjid|mosque|парк|park|mall|молл|трц|тц|вокзал|аэропорт|airport|университет|university)(?=$|[^\p{L}\p{N}_])/iu,
114
+ }),
115
+ });
116
+
117
+ function hasExplicitContext(value, candidate) {
118
+ const pattern = CONTEXT_PATTERNS[candidate.type];
119
+ if (!pattern) return false;
120
+ const before = value.slice(Math.max(0, candidate.start - 40), candidate.start);
121
+ const after = value.slice(candidate.end, Math.min(value.length, candidate.end + 40));
122
+ return pattern.before.test(before) || pattern.after.test(after) || Boolean(pattern.inside?.test(candidate.matchedText));
123
+ }
124
+
125
+ function overlaps(a, b) {
126
+ return a.start < b.end && b.start < a.end;
127
+ }
128
+
129
+ function containsSpan(outer, inner) {
130
+ return outer.start <= inner.start && outer.end >= inner.end;
131
+ }
132
+
133
+ const SEMANTIC_CHAR_RE = /[\p{L}\p{N}_]/u;
134
+ function semanticBounds(value, match) {
135
+ let start = match.index ?? 0;
136
+ let end = start + match[0].length;
137
+ while (start < end && !SEMANTIC_CHAR_RE.test(value[start])) start += 1;
138
+ while (end > start && !SEMANTIC_CHAR_RE.test(value[end - 1])) end -= 1;
139
+ return { start, end };
83
140
  }
84
141
 
85
- function metroOverlapsArea(item, data) {
86
- const itemKeys = new Set([item.name, ...(item.aliases || [])].map(normalizeForMatch).filter(Boolean));
87
- return ['microdistricts', 'localAreas'].some((key) => (data?.[key] || []).some((area) =>
88
- [area.name, ...(area.aliases || [])].map(normalizeForMatch).some((value) => itemKeys.has(value)),
89
- ));
142
+ function publicMatch(candidate) {
143
+ const { start, end, matchedText, explicitContext, ...result } = candidate;
144
+ return Object.freeze(result);
90
145
  }
91
146
 
92
147
  function findEntryMatches(text, cityName, data) {
93
148
  const value = String(text || '');
94
- const matches = [];
149
+ const raw = [];
95
150
  for (const key of LOCATION_LIST_KEYS) {
96
151
  for (const item of data?.[key] || []) {
97
152
  const match = value.match(item?.re);
98
153
  if (!match) continue;
99
- if (key === 'metro' && metroOverlapsArea(item, data) && !isExplicitMetroContext(value, match)) continue;
100
- matches.push(Object.freeze({
154
+ const { start, end } = semanticBounds(value, match);
155
+ const candidate = {
101
156
  country: item.country || null,
102
157
  city: cityName,
103
158
  type: item.entityType || TYPE_BY_KEY[key] || key,
@@ -108,10 +163,39 @@ function findEntryMatches(text, cityName, data) {
108
163
  parent: item.parent || item.district || null,
109
164
  confidence: item.confidence || null,
110
165
  language: item.language || null,
111
- }));
166
+ start,
167
+ end,
168
+ matchedText: value.slice(start, end),
169
+ explicitContext: false,
170
+ };
171
+ candidate.explicitContext = hasExplicitContext(value, candidate);
172
+ raw.push(candidate);
112
173
  }
113
174
  }
114
- return matches;
175
+
176
+ const filtered = raw.filter((candidate) => {
177
+ const peers = raw.filter((other) => other !== candidate && overlaps(candidate, other));
178
+ if (candidate.explicitContext) return true;
179
+
180
+ // An explicit type marker is stronger than a same-surface alias of another type:
181
+ // "метро Минор" is a station, while "Минор махалла" is the mahalla.
182
+ if (peers.some((peer) => peer.explicitContext && peer.type !== candidate.type)) return false;
183
+
184
+ // Prefer the semantically longer phrase when one alias contains another, e.g.
185
+ // "Сергели машинный базар" over the bare district/metro token "Сергели".
186
+ const length = candidate.end - candidate.start;
187
+ if (peers.some((peer) => containsSpan(peer, candidate) && (peer.end - peer.start) > length)) return false;
188
+
189
+ // Bare station names that are also real neighborhoods/mahallas/districts are
190
+ // not enough to assert metro semantics. Explicit metro context restores them.
191
+ if (candidate.type === 'metro' && peers.some((peer) =>
192
+ ['district', 'microdistrict', 'mahalla', 'local_area'].includes(peer.type)
193
+ )) return false;
194
+
195
+ return true;
196
+ });
197
+
198
+ return filtered.map(publicMatch);
115
199
  }
116
200
 
117
201
  function isAmbiguousMatch(match, countryCode) {
package/src/geo.js CHANGED
@@ -112,16 +112,17 @@ export function tashkentMetroLabels() {
112
112
  return Object.fromEntries(TASHKENT_METRO.map((item) => [item.name, { ru: item.labels.ru, en: item.labels.en, line: item.line }]));
113
113
  }
114
114
 
115
- const area = (name, aliases) => deepFreeze({ canonical: name, name, type: 'local_area', country: 'UZ', city: 'Tashkent', aliases });
115
+ // Historical consumer name retained, but entries now carry their actual semantic type.
116
+ const area = (name, aliases, type = 'local_area') => deepFreeze({ canonical: name, name, type, country: 'UZ', city: 'Tashkent', aliases });
116
117
 
117
118
  export const TASHKENT_AREAS = Object.freeze({
118
119
  Almazar: Object.freeze([
119
- area('Sebzar', ['себзар', 'sebzar', 'ц 17', 'ц 18', 'c 17', 'c 18']),
120
+ area('Sebzar', ['себзар', 'sebzar', 'ц 17', 'ц 18', 'c 17', 'c 18'], 'microdistrict'),
120
121
  area('Karakamysh-1/2', ['каракамыш 1 2', 'қорақамиш 1 2', 'qoraqamish 1 2', 'karakamish 1 2']),
121
122
  area('Karakamysh-2/3', ['каракамыш 2 3', 'қорақамиш 2 3', 'qoraqamish 2 3', 'karakamish 2 3']),
122
123
  area('Karakamysh-2/4', ['каракамыш 2 4', 'қорақамиш 2 4', 'qoraqamish 2 4', 'karakamish 2 4']),
123
124
  area('Karakamysh-2/5', ['каракамыш 2 5', 'қорақамиш 2 5', 'qoraqamish 2 5', 'karakamish 2 5']),
124
- area('Olympia', ['олимпия', 'olimpiya', 'olympia']), area('Vuzgorodok', ['вузгородок', 'вуз городок', 'vuzgorodok']),
125
+ area('Olympia', ['олимпия', 'olimpiya', 'olympia'], 'microdistrict'), area('Vuzgorodok', ['вузгородок', 'вуз городок', 'vuzgorodok']),
125
126
  area('Medgorodok', ['медгородок', 'мед городок', 'medgorodok']), area('Takhtapul', ['тахтапуль', 'тахтапул', 'taxtapul', 'takhtapul']),
126
127
  area('Chimbay', ['чимбай', 'chimboy', 'chimbay']),
127
128
  ]),
@@ -132,12 +133,12 @@ export const TASHKENT_AREAS = Object.freeze({
132
133
  'Mirzo Ulugbek': Object.freeze([
133
134
  area('Buyuk Ipak Yuli', ['буюк ипак йули', 'буюк ипак йўли', 'buyuk ipak yuli', "buyuk ipak yo'li", 'ц 1', 'c 1']),
134
135
  area('Alay', ['олой', 'алайский', 'алайск', 'alay', 'ц 2', 'c 2']),
135
- ...[1, 2, 3, 4, 6].map((n) => area(`Karasu-${n}`, [`карасу ${n}`, `қорасув ${n}`, `qorasuv ${n}`, `karasu ${n}`])),
136
- ...[1, 2, 3, 4].map((n) => area(`TTZ-${n}`, [`ттз ${n}`, `ttz ${n}`])),
136
+ ...[1, 2, 3, 4].map((n) => area(`Karasu-${n}`, [`карасу ${n}`, `қорасув ${n}`, `qorasuv ${n}`, `karasu ${n}`], 'microdistrict')),
137
+ area('Karasu-6', ['карасу 6', 'қорасув 6', 'qorasuv 6', 'karasu 6'], 'microdistrict'),
138
+ ...[1, 2, 3, 4].map((n) => area(`TTZ-${n}`, [`ттз ${n}`, `ttz ${n}`], 'microdistrict')),
137
139
  area('Yalangach', ['ялангач', 'yalangach', "yalang'och"]), area('Feruza', ['феруза', 'feruza']), area('Geofizika', ['геофизика', 'поселок геофизиков', 'geofizika']),
138
140
  ]),
139
141
  Sergeli: Object.freeze([
140
- area('Sergeli Car Bazaar', ['сергели машинный базар', 'сергели машина бозор', 'sergeli moshina bozor', 'sergile moshena bozor', 'sergele moshina bozor']),
141
142
  area('Yangi Sergeli', ['янги сергели', 'yangi sergeli']), area('Stroygorod', ['стройгород', 'stroygorod']),
142
143
  ]),
143
144
  Chilanzar: Object.freeze([
@@ -157,13 +158,13 @@ export const TASHKENT_AREAS = Object.freeze({
157
158
  area('Rakat', ['ракат', 'rakat']), area('Rakatboshi', ['ракатбоши', 'rakatboshi']),
158
159
  ]),
159
160
  Yangihayot: Object.freeze([
160
- area('Uzgarish', ['узгарыш', 'ўзгариш', "o'zgarish", 'uzgarish']), area('Dustlik-1', ['дустлик 1', 'дўстлик 1', "do'stlik 1", 'dustlik 1']),
161
- area('Dustlik-2', ['дустлик 2', 'дўстлик 2', "do'stlik 2", 'dustlik 2']), area('Yangi Choshtepa', ['янги чоштепа', 'yangi choshtepa']), area('Sputnik', ['спутник', 'йўлдош', "yo'ldosh", 'yoldosh']),
161
+ area('Uzgarish', ['узгарыш', 'ўзгариш', "o'zgarish", 'uzgarish']), area('Dustlik-1', ['дустлик 1', 'дўстлик 1', "do'stlik 1", 'dustlik 1'], 'microdistrict'),
162
+ area('Dustlik-2', ['дустлик 2', 'дўстлик 2', "do'stlik 2", 'dustlik 2'], 'microdistrict'), area('Yangi Choshtepa', ['янги чоштепа', 'yangi choshtepa'], 'microdistrict'), area('Sputnik', ['спутник', 'йўлдош', "yo'ldosh", 'yoldosh'], 'microdistrict'),
162
163
  ]),
163
164
  Yashnobod: Object.freeze([
164
165
  area('Kuylyuk Center', ['куйлюк центр', 'куйлик центр', "qo'yliq markaz", 'kuylyuk center']),
165
166
  ...[1, 2, 3, 4].map((n) => area(`Aviasozlar-${n}`, [`авиасозлар ${n}`, `городок авиастроителей ${n}`, `aviasozlar ${n}`])),
166
167
  ...[1, 2, 3, 4].map((n) => area(`Tuzel-${n}`, [`тузель ${n}`, `tuzel ${n}`])),
167
- area('Tashselmash', ['ташсельмаш', 'tashselmash']), area('Alimkent', ['алимкент', 'olimkent', 'alimkent']), area('Shohimardon', ['шохимардон', 'shohimardon']),
168
+ area('Tashselmash', ['ташсельмаш', 'tashselmash'], 'microdistrict'), area('Alimkent', ['алимкент', 'olimkent', 'alimkent']), area('Shohimardon', ['шохимардон', 'shohimardon']),
168
169
  ]),
169
170
  });
@@ -49,7 +49,7 @@ export const GEOGRAPHY_DISPLAY_NAMES = Object.freeze({
49
49
  district: Object.freeze({
50
50
  Chilanzar: 'Чиланзар', Yunusabad: 'Юнусабад', 'Mirzo Ulugbek': 'Мирзо-Улугбек',
51
51
  Yakkasaray: 'Яккасарай', Shaykhantahur: 'Шайхантахур', Yashnobod: 'Яшнабад', Sergeli: 'Сергели',
52
- Uchtepa: 'Учтепа', Mirobod: 'Мирабад', Bektemir: 'Бектемир', Olmazor: 'Алмазар',
52
+ Uchtepa: 'Учтепа', Mirobod: 'Мирабад', Bektemir: 'Бектемир', Almazar: 'Алмазар', Yangihayot: 'Янгихаёт',
53
53
  Almaly: 'Алмалинский', Bostandyk: 'Бостандыкский', Medeu: 'Медеуский', Auezov: 'Ауэзовский',
54
54
  Turksib: 'Турксибский', Nauryzbay: 'Наурызбайский', Alatau: 'Алатауский', Zhetysu: 'Жетысуский',
55
55
  Podil: 'Подол', Pechersk: 'Печерск', Pecherskyi: 'Печерский', Obolon: 'Оболонь',
@@ -3,6 +3,7 @@ export type CandidateContacts = Readonly<{ phone?: string; email?: string; teleg
3
3
 
4
4
  export function extractCandidateGender(value: unknown): CandidateGender | undefined;
5
5
  export function extractCandidateName(value: unknown): string;
6
+ export function isCandidateNameHidden(value: unknown): boolean;
6
7
  export function extractCandidateAge(value: unknown, now?: Date): number | null;
7
8
  export function parseCandidateExperienceValue(value: unknown): number | null;
8
9
  export function extractCandidateExperienceYears(value: unknown): number | null;
@@ -40,6 +40,14 @@ export function extractCandidateName(value) {
40
40
  .trim().replace(/\s{2,}/g, ' ').slice(0, 100);
41
41
  }
42
42
 
43
+ const HIDDEN_NAME_RE = /^(?:[??��]{2,}|(?:фио|ф\.и\.о\.?|піб|имя|ім['’ʼ]я|name|full name)?\s*(?:скрыт\p{L}*|прихован\p{L}*|hidden|yashiril\p{L}*|ascuns)|не\s+указан\p{L}*|not\s+specified|anonymous|аноним\p{L}*|onlayn|online|resume|резюме)$/iu;
44
+
45
+ /** Detects a source board's "name hidden"/anonymous placeholder value. */
46
+ export function isCandidateNameHidden(value) {
47
+ const text = String(value ?? '').trim();
48
+ return text ? HIDDEN_NAME_RE.test(text) : false;
49
+ }
50
+
43
51
  export function extractCandidateAge(value, now = new Date()) {
44
52
  const text = String(value || '');
45
53
  const patterns = [
package/src/hiring.js CHANGED
@@ -66,7 +66,7 @@ export const EMPLOYMENT_TYPES = Object.freeze([
66
66
  uzLatn: ["to'liq bandlik", 'to‘liq bandlik', "to'liq kun", 'toliq ish kuni'], uzCyrl: ['тўлиқ бандлик', 'тўлиқ кун', 'тўлиқ иш куни'], kk: ['толық жұмыс күні', 'толық жұмыспен қамту'],
67
67
  }),
68
68
  group('partTime', {
69
- ru: ['частичная занятость', 'неполная занятость', 'неполный день', 'неполный рабочий день', 'подработка'], en: ['part time', 'part-time', 'part time employment'], uk: ['часткова зайнятість', 'неповний день', 'підробіток'], ro: ['part-time', 'jumătate de normă', 'jumatate de norma', 'program parțial', 'program partial'],
69
+ ru: ['частичная занятость', 'неполная занятость', 'неполный день', 'неполный рабочий день', 'подработка'], en: ['part time', 'part-time', 'part time employment'], uk: ['часткова зайнятість', 'неповна зайнятість', 'неповний день', 'підробіток'], ro: ['part-time', 'jumătate de normă', 'jumatate de norma', 'program parțial', 'program partial'],
70
70
  uzLatn: ['yarim stavka', 'qisman bandlik', "qo'shimcha ish"], uzCyrl: ['ярим ставка', 'қисман бандлик', 'қўшимча иш'], kk: ['жартылай жұмыс', 'толық емес жұмыс күні', 'қосымша жұмыс'],
71
71
  }),
72
72
  group('contract', {
@@ -95,7 +95,7 @@ export const EMPLOYMENT_TYPES = Object.freeze([
95
95
  ]);
96
96
 
97
97
  export const WORK_MODES = Object.freeze([
98
- group('remote', { ru: ['удалённо', 'удаленно', 'удалённая работа', 'дистанционно'], en: ['remote', 'work from home', 'wfh'], uk: ['віддалено', 'дистанційно', 'віддалена робота'], ro: ['remote', 'de acasă', 'de acasa', 'muncă la distanță', 'munca la distanta'], uzLatn: ['masofadan', 'masofaviy', 'uydan ishlash'], uzCyrl: ['масофадан', 'масофавий', 'уйдан ишлаш'], kk: ['қашықтан', 'қашықтан жұмыс', 'үйден жұмыс'] }),
98
+ group('remote', { ru: ['удалённо', 'удаленно', 'удалённая работа', 'дистанционно'], en: ['remote', 'work from home', 'wfh'], uk: ['віддалено', 'дистанційно', 'віддалена робота', 'дистанційна робота', 'дистанційний формат'], ro: ['remote', 'de acasă', 'de acasa', 'muncă la distanță', 'munca la distanta'], uzLatn: ['masofadan', 'masofaviy', 'uydan ishlash'], uzCyrl: ['масофадан', 'масофавий', 'уйдан ишлаш'], kk: ['қашықтан', 'қашықтан жұмыс', 'үйден жұмыс'] }),
99
99
  group('hybrid', { ru: ['гибрид', 'гибридный формат'], en: ['hybrid', 'hybrid work'], uk: ['гібрид', 'гібридний формат'], ro: ['hibrid', 'mod hibrid'], uzLatn: ['gibrid'], uzCyrl: ['гибрид'], kk: ['гибрид', 'аралас формат'] }),
100
100
  group('onsite', { ru: ['офис', 'в офисе', 'на месте', 'очно'], en: ['onsite', 'on-site', 'office', 'in person'], uk: ['офіс', 'в офісі', 'на місці'], ro: ['la birou', 'on-site', 'la sediu', 'fizic'], uzLatn: ['ofisda', 'joyida'], uzCyrl: ['офисда', 'жойида'], kk: ['кеңседе', 'офисте', 'орнында'] }),
101
101
  ]);
@@ -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 { canonicalTashkentDistrict, canonicalTashkentMetro } from './geo.js';
4
+ import { matchTashkentHousingDistrict, matchTashkentHousingMetro } from './tashkent-housing-geography.js';
5
5
  import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
6
6
  import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
7
7
  import { parseHousingListingFields } from './housing-listing-fields.js';
@@ -193,8 +193,8 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
193
193
  const audience = parseHousingAudience(text);
194
194
  const perPersonPrice = parseHousingPerPersonPrice(text, { country });
195
195
  const observedAmenities = parseHousingObservedAmenities(text);
196
- const district = canonicalTashkentDistrict(text);
197
- const metro = canonicalTashkentMetro(text);
196
+ const district = matchTashkentHousingDistrict(text)?.name || null;
197
+ const metro = matchTashkentHousingMetro(text)?.name || null;
198
198
  const parsedRc = specificResidentialComplex(text) || parseHousingResidentialComplex(text);
199
199
  const commission = parseHousingCommission(text);
200
200
  const commissionAmount = parseHousingCommissionAmount(text);
@@ -7,6 +7,7 @@ export const LOCATION_LIST_KEYS = Object.freeze([
7
7
  'localAreas',
8
8
  'suburbs',
9
9
  'settlements',
10
+ 'developmentAreas',
10
11
  'residentialComplexes',
11
12
  'metro',
12
13
  'streets',
package/src/locations.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  matchUkraineSecondaryCity,
8
8
  } from './location-data.js';
9
9
  import { LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
10
+ import { aliasesToRegex } from './normalization.js';
10
11
  import { KZ_LOCATION_EXTENSIONS } from './kz-location-extensions.js';
11
12
  import { UZ_LOCATION_EXTENSIONS } from './uz-location-extensions.js';
12
13
  import { UA_MAJOR_LOCATION_EXTENSIONS } from './ua-location-extensions-major.js';
@@ -25,16 +26,80 @@ export const UA_EXTRA_LOCATION_DICTIONARIES = Object.freeze({
25
26
  }),
26
27
  });
27
28
 
29
+ const TASHKENT_UNSUPPORTED_SEED_MICRODISTRICTS = new Set([
30
+ 'Sergeli',
31
+ 'Yunusabad-20',
32
+ 'Yunusabad-21',
33
+ 'Yunusabad-22',
34
+ ]);
35
+
36
+ // The UZ seed is compatibility storage. In 0.3 semantic corrections are
37
+ // applied before merging it with the canonical Uzbekistan extension layer.
38
+ const UZ_BASE_LOCATION_DICTIONARIES = Object.freeze({
39
+ ...(BASE_LOCATION_DICTIONARIES.UZ || {}),
40
+ Tashkent: Object.freeze({
41
+ ...(BASE_LOCATION_DICTIONARIES.UZ?.Tashkent || {}),
42
+ microdistricts: Object.freeze(
43
+ (BASE_LOCATION_DICTIONARIES.UZ?.Tashkent?.microdistricts || [])
44
+ .filter(({ name }) => !TASHKENT_UNSUPPORTED_SEED_MICRODISTRICTS.has(name)),
45
+ ),
46
+ residentialComplexes: Object.freeze(
47
+ (BASE_LOCATION_DICTIONARIES.UZ?.Tashkent?.residentialComplexes || [])
48
+ .filter(({ name }) => name !== 'Tashkent City'),
49
+ ),
50
+ }),
51
+ });
52
+
53
+ function normalizeUzSemanticLocations(country) {
54
+ const tashkent = country?.Tashkent;
55
+ if (!tashkent) return country;
56
+ const qorasuv = (tashkent.microdistricts || []).find(({ name }) => name === 'Qorasuv');
57
+ if (!qorasuv) return country;
58
+
59
+ // Bare Qorasuv is ambiguous with numbered Qorasuv/Karasu blocks. The
60
+ // umbrella area therefore requires an area/massif/daha form in free text.
61
+ const qorasuvAliases = Object.freeze([...new Set([
62
+ ...(qorasuv.aliases || []).filter((alias) => !/^(?:qorasuv|korasuv|корасув|карасу)$/iu.test(String(alias).trim())),
63
+ 'Qorasuv dahasi',
64
+ 'Qorasuv daha',
65
+ 'Қорасув даҳаси',
66
+ 'Корасув дахаси',
67
+ 'Карасу даха',
68
+ ])]);
69
+ const qorasuvArea = Object.freeze({
70
+ ...qorasuv,
71
+ type: 'local_area',
72
+ entityType: 'local_area',
73
+ parent: 'Mirzo Ulugbek',
74
+ aliases: qorasuvAliases,
75
+ re: aliasesToRegex(qorasuvAliases),
76
+ });
77
+
78
+ return Object.freeze({
79
+ ...country,
80
+ Tashkent: Object.freeze({
81
+ ...tashkent,
82
+ microdistricts: Object.freeze((tashkent.microdistricts || []).filter(({ name }) => name !== 'Qorasuv')),
83
+ localAreas: Object.freeze([
84
+ ...(tashkent.localAreas || []),
85
+ qorasuvArea,
86
+ ]),
87
+ }),
88
+ });
89
+ }
90
+
91
+ const UZ_LOCATION_DICTIONARIES = normalizeUzSemanticLocations(mergeLocationCountries(
92
+ UZ_BASE_LOCATION_DICTIONARIES,
93
+ UZ_LOCATION_EXTENSIONS,
94
+ ));
95
+
28
96
  const COUNTRY_LOCATION_DICTIONARIES = Object.freeze({
29
97
  ...BASE_LOCATION_DICTIONARIES,
30
98
  KZ: mergeLocationCountries(
31
99
  BASE_LOCATION_DICTIONARIES.KZ || {},
32
100
  KZ_LOCATION_EXTENSIONS,
33
101
  ),
34
- UZ: mergeLocationCountries(
35
- BASE_LOCATION_DICTIONARIES.UZ || {},
36
- UZ_LOCATION_EXTENSIONS,
37
- ),
102
+ UZ: UZ_LOCATION_DICTIONARIES,
38
103
  UA: mergeLocationCountries(
39
104
  UA_EXTRA_LOCATION_DICTIONARIES,
40
105
  UA_MAJOR_LOCATION_EXTENSIONS,
@@ -1,6 +1,13 @@
1
1
  import { TASHKENT_AREAS } from './geo.js';
2
2
 
3
- const area = (name, aliases) => Object.freeze({ name, aliases: Object.freeze(aliases) });
3
+ const area = (name, aliases, type = 'local_area') => Object.freeze({
4
+ canonical: name,
5
+ name,
6
+ type,
7
+ country: 'UZ',
8
+ city: 'Tashkent',
9
+ aliases: Object.freeze(aliases),
10
+ });
4
11
 
5
12
  /** Additions to the core Tashkent catalog; entries here do not duplicate geo.js rows. */
6
13
  export const TASHKENT_AREA_ADDITIONS = Object.freeze({
@@ -18,7 +25,7 @@ export const TASHKENT_AREA_ADDITIONS = Object.freeze({
18
25
  area('Binokor-2', ['бинокор 2', 'binokor 2']),
19
26
  area('Majnuntol', ['мажнунтол', 'majnuntol']),
20
27
  area('Olima Oshirova', ['олима оширова', 'olima oshirova']),
21
- area('Bektemir', ['бектемир массив', 'bektemir massivi']),
28
+ area('Bektemir', ['бектемир массив', 'bektemir massivi'], 'microdistrict'),
22
29
  ]),
23
30
  Mirobod: Object.freeze([
24
31
  area('Farovon', ['фаровон', 'farovon']),
@@ -32,15 +39,15 @@ export const TASHKENT_AREA_ADDITIONS = Object.freeze({
32
39
  area('Riyoziy', ['риёзий', 'riyoziy']),
33
40
  ]),
34
41
  Sergeli: Object.freeze([
35
- area('Sergeli-2 G-40', ['сергели 2 г 40', 'sergeli 2 g 40']),
42
+ area('Sergeli-2 G-40', ['сергели 2 г 40', 'sergeli 2 g 40'], 'microdistrict'),
36
43
  area('Babur Quarter', ['квартал бабур', 'бабур квартал', 'babur kvartal']),
37
44
  ]),
38
45
  Uchtepa: Object.freeze([
39
46
  area('Al-Khorezmi-2', ['аль хорезми 2', 'ал хорезми 2', 'al xorazmiy 2', 'al khorezmi 2']),
40
- area('Shark', ['массив шарк', 'шарк массив', 'sharq massivi']),
47
+ area('Shark', ['массив шарк', 'шарк массив', 'sharq massivi'], 'microdistrict'),
41
48
  ]),
42
49
  Chilanzar: Object.freeze([
43
- area('Almazar Massif', ['массив алмазар', 'массив олмазор', 'almazar massivi', 'olmazor massivi']),
50
+ area('Almazar Massif', ['массив алмазар', 'массив олмазор', 'almazar massivi', 'olmazor massivi'], 'microdistrict'),
44
51
  ]),
45
52
  Shaykhantahur: Object.freeze([
46
53
  area('Labzak C-13', ['лабзак ц 13', 'лабзак ц-13', 'labzak c 13', 'labzak c-13']),
@@ -55,16 +62,11 @@ export const TASHKENT_AREA_ADDITIONS = Object.freeze({
55
62
  area('Katta Hasanboy', ['катта хасанбой', 'katta hasanboy']),
56
63
  ]),
57
64
  Yakkasaray: Object.freeze([
58
- area('Glinka', ['глинка', 'glinka']),
59
- area('Bobur', ['массив бобур', 'bobur massivi']),
60
- area('Konstitutsiya', ['конституция массив', 'konstitutsiya massivi']),
65
+ area('Bobur', ['массив бобур', 'bobur massivi'], 'microdistrict'),
66
+ area('Konstitutsiya', ['конституция массив', 'konstitutsiya massivi'], 'microdistrict'),
61
67
  area('Hamid Sulaymonov', ['хамид сулаймонов', 'hamid sulaymonov']),
62
68
  ]),
63
- Yangihayot: Object.freeze([
64
- area('Yangi Choshtepa', ['янги чоштепа', 'yangi choshtepa']),
65
- ]),
66
69
  Yashnobod: Object.freeze([
67
- area('Aviasozlar-3', ['авиасозлар 3', 'авиасозлар-3', 'aviasozlar 3', 'aviasozlar-3']),
68
70
  area('Asalabad-1', ['асалабад 1', 'asalobod 1', 'asalabad 1']),
69
71
  area('Asalabad-2', ['асалабад 2', 'asalobod 2', 'asalabad 2']),
70
72
  area('Mumtoz', ['мумтаз', 'mumtoz']),
@@ -27,6 +27,15 @@ export interface TashkentMetroEntry {
27
27
  readonly re: RegExp;
28
28
  }
29
29
 
30
+ export interface TashkentDistrictEntry {
31
+ readonly canonical: string;
32
+ readonly name: string;
33
+ readonly type: 'district';
34
+ readonly country: 'UZ';
35
+ readonly city: 'Tashkent';
36
+ readonly re: RegExp;
37
+ }
38
+
30
39
  export const TASHKENT_HOUSING_LANDMARKS: readonly TashkentHousingLocationEntry[];
31
40
  export const TASHKENT_NUMBERED_AREA_ALIASES: Readonly<Record<string, readonly string[]>>;
32
41
 
@@ -34,5 +43,6 @@ export function matchTashkentHousingLandmarks(value: unknown): TashkentHousingLo
34
43
  export function matchTashkentNumberedArea(value: unknown, canonical: string): TashkentNumberedAreaMatch | null;
35
44
  export function hasTashkentAreaAlias(value: unknown, canonical: string): boolean;
36
45
  export function hasExplicitTashkentDistrict(value: unknown, canonical: string): boolean;
46
+ export function matchTashkentHousingDistrict(value: unknown): TashkentDistrictEntry | null;
37
47
  export function matchTashkentHousingMetro(value: unknown): TashkentMetroEntry | null;
38
48
  export function matchTashkentHousingTransit(value: unknown): TashkentHousingLocationEntry | null;
@@ -1,5 +1,5 @@
1
- import { aliasesToRegex, escapeRegex, normalizeForMatch } from './normalization.js';
2
- import { TASHKENT_METRO } from './geo.js';
1
+ import { aliasesOf, aliasesToRegex, escapeRegex, normalizeForMatch } from './normalization.js';
2
+ import { TASHKENT_DISTRICTS, TASHKENT_METRO } from './geo.js';
3
3
 
4
4
  function locationEntry(name, category, aliases = [], options = {}) {
5
5
  const all = [...new Set([name, ...aliases].filter(Boolean))];
@@ -16,6 +16,19 @@ function locationEntry(name, category, aliases = [], options = {}) {
16
16
  });
17
17
  }
18
18
 
19
+ const TASHKENT_HOUSING_DISTRICTS = Object.freeze(TASHKENT_DISTRICTS.map((district) => {
20
+ const aliases = [...new Set([district.canonical, ...aliasesOf(district)].filter(Boolean))];
21
+ return Object.freeze({
22
+ canonical: district.canonical,
23
+ name: district.canonical,
24
+ type: 'district',
25
+ country: 'UZ',
26
+ city: 'Tashkent',
27
+ aliases: Object.freeze(aliases),
28
+ re: aliasesToRegex(aliases),
29
+ });
30
+ }));
31
+
19
32
  /**
20
33
  * Housing-oriented Tashkent names that occur in listings as landmarks even when
21
34
  * the generic POI catalogue cannot safely classify the bare token on its own.
@@ -30,7 +43,7 @@ export const TASHKENT_HOUSING_LANDMARKS = Object.freeze([
30
43
  locationEntry('Novomoskovskaya', 'residential_complex', [
31
44
  'Новомосковская', 'Новомосковской', 'Novomoskovskaya',
32
45
  ]),
33
- locationEntry('Yangi Choshtepa', 'local_area', [
46
+ locationEntry('Yangi Choshtepa', 'microdistrict', [
34
47
  'Янги Чоштепа', 'Янги чоштепа', 'Yangi Choshtepa',
35
48
  ]),
36
49
  locationEntry('Glinka', 'landmark', [
@@ -89,6 +102,50 @@ function hasRequiredContext(entry, text, start, end) {
89
102
  return entry.contextRe?.test(around) ?? false;
90
103
  }
91
104
 
105
+ function matchContext(text, match, beforeRe, afterRe, insideRe = null) {
106
+ const start = match.index ?? 0;
107
+ const end = start + match[0].length;
108
+ const before = text.slice(Math.max(0, start - 36), start);
109
+ const after = text.slice(end, Math.min(text.length, end + 36));
110
+ return beforeRe.test(before) || afterRe.test(after) || Boolean(insideRe?.test(match[0]));
111
+ }
112
+
113
+ const METRO_BEFORE_RE = /(?:метро|metro|станц(?:ия|ии)?|station|ст\.?\s*м\.?)\s*[:\-–—]?\s*$/iu;
114
+ const METRO_AFTER_RE = /^\s*[:\-–—]?\s*(?:метро|metro|станц(?:ия|ии)?|station)(?=$|[^\p{L}\p{N}_])/iu;
115
+ const METRO_INSIDE_RE = /(?:метро|metro|станц(?:ия|ии)?|station)/iu;
116
+ const DISTRICT_BEFORE_RE = /(?:район|р-н|рн|туман\p{L}{0,4}|tumani|district)\s*[:\-–—]?\s*$/iu;
117
+ const DISTRICT_AFTER_RE = /^\s*[:\-–—]?\s*(?:район|р-н|рн|туман\p{L}{0,4}|tumani|district)(?=$|[^\p{L}\p{N}_])/iu;
118
+ const DISTRICT_INSIDE_RE = /(?:район|р-н|рн|туман\p{L}{0,4}|tumani|district)/iu;
119
+ const AREA_BEFORE_RE = /(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа)\s*[:\-–—]?\s*$/iu;
120
+ const AREA_AFTER_RE = /^\s*(?:\d{1,2}[aа]?\s*)?[:\-–—]?\s*(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа)(?=$|[^\p{L}\p{N}_])/iu;
121
+ const AREA_INSIDE_RE = /(?:массив(?:и)?|massiv(?:i)?|жилмассив|ж\/м|микрорайон|мкр\.?|mavze(?:si)?|мавзе(?:си)?|квартал|kvartal|daha|даха|даҳа)/iu;
122
+ const MAHALLA_BEFORE_RE = /(?:махалл(?:а|я)|маҳалла(?:си)?|mahalla(?:si)?|mfy|мфй)\s*[:\-–—]?\s*$/iu;
123
+ const MAHALLA_AFTER_RE = /^\s*[:\-–—]?\s*(?:махалл(?:а|я)|маҳалла(?:си)?|mahalla(?:si)?|mfy|мфй)(?=$|[^\p{L}\p{N}_])/iu;
124
+ const MAHALLA_INSIDE_RE = /(?:махалл(?:а|я)|маҳалла(?:си)?|mahalla(?:si)?|mfy|мфй)/iu;
125
+ const LANDMARK_AFTER_RE = /^\s*[:\-–—]?\s*(?:базар|рынок|bozor(?:i)?|мечет\p{L}*|масжид|masjid|mosque|парк|park|mall|молл|вокзал|аэропорт|airport)(?=$|[^\p{L}\p{N}_])/iu;
126
+ const LANDMARK_BEFORE_RE = /(?:базар|рынок|bozor(?:i)?|мечет\p{L}*|масжид|masjid|mosque|парк|park|mall|молл|вокзал|аэропорт|airport)\s*[:\-–—]?\s*$/iu;
127
+ const LANDMARK_INSIDE_RE = /(?:базар|рынок|bozor(?:i)?|мечет\p{L}*|масжид|masjid|mosque|парк|park|mall|молл|вокзал|аэропорт|airport)/iu;
128
+
129
+ function hasExplicitMetroContext(text, match) {
130
+ return matchContext(text, match, METRO_BEFORE_RE, METRO_AFTER_RE, METRO_INSIDE_RE);
131
+ }
132
+
133
+ function hasExplicitDistrictContext(text, match) {
134
+ return matchContext(text, match, DISTRICT_BEFORE_RE, DISTRICT_AFTER_RE, DISTRICT_INSIDE_RE);
135
+ }
136
+
137
+ function hasExplicitAreaContext(text, match) {
138
+ return matchContext(text, match, AREA_BEFORE_RE, AREA_AFTER_RE, AREA_INSIDE_RE);
139
+ }
140
+
141
+ function hasExplicitMahallaContext(text, match) {
142
+ return matchContext(text, match, MAHALLA_BEFORE_RE, MAHALLA_AFTER_RE, MAHALLA_INSIDE_RE);
143
+ }
144
+
145
+ function hasExplicitLandmarkContext(text, match) {
146
+ return matchContext(text, match, LANDMARK_BEFORE_RE, LANDMARK_AFTER_RE, LANDMARK_INSIDE_RE);
147
+ }
148
+
92
149
  /** Return all housing landmarks in stable catalogue-priority order. */
93
150
  export function matchTashkentHousingLandmarks(value) {
94
151
  const text = String(value ?? '');
@@ -180,25 +237,51 @@ export function hasExplicitTashkentDistrict(value, canonical) {
180
237
  return new RegExp(`(?:^|\\s)(?:${alternatives})\\s+${DISTRICT_MARKER}(?:\\s|$)`, 'iu').test(text);
181
238
  }
182
239
 
240
+ /** Resolve administrative districts only when the surrounding housing context agrees. */
241
+ export function matchTashkentHousingDistrict(value) {
242
+ const text = String(value ?? '');
243
+ if (!text) return null;
244
+ for (const district of TASHKENT_HOUSING_DISTRICTS) {
245
+ const match = text.match(district.re);
246
+ if (!match) continue;
247
+ if (hasExplicitDistrictContext(text, match)) return district;
248
+ if (hasExplicitMetroContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) continue;
249
+ if (matchTashkentNumberedArea(text, district.name)) continue;
250
+ return district;
251
+ }
252
+ return null;
253
+ }
254
+
183
255
  const EXTRA_METRO_ALIASES = Object.freeze({
184
256
  Sergeli: Object.freeze(['Sergile', 'Sergele']),
185
257
  });
186
258
 
187
259
  const QOYLIQ_MASSIF_RE = /(?:куйлюк|куйлик|kuylyuk|kuyliq|qoyliq|qo[‘’ʻ']?yliq|қўйлиқ)(?:\s+\d{1,2})?\s+(?:массив(?:и)?|massiv(?:i)?)/iu;
260
+ const METRO_NUMBERED_AREA = Object.freeze({
261
+ Chilonzor: 'Chilanzar',
262
+ Sergeli: 'Sergeli',
263
+ Yunusobod: 'Yunusabad',
264
+ Yangihayot: 'Yangihayot',
265
+ });
188
266
 
189
- /** Resolve listing typos/transliterations to an existing canonical metro entry. */
267
+ /** Resolve listing typos/transliterations while respecting explicit non-metro geography. */
190
268
  export function matchTashkentHousingMetro(value) {
191
269
  const text = String(value ?? '');
192
270
  if (!text) return null;
193
271
  for (const station of TASHKENT_METRO) {
194
- if (!station.re.test(text)) continue;
195
- // Qoyliq is also a large housing massif. In housing text an explicit
196
- // "массив/massiv" marker wins over the homonymous metro station.
272
+ const match = text.match(station.re);
273
+ if (!match) continue;
274
+ if (hasExplicitMetroContext(text, match)) return station;
197
275
  if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
276
+ const areaCanonical = METRO_NUMBERED_AREA[station.name];
277
+ if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
278
+ if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
198
279
  return station;
199
280
  }
200
281
  for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
201
- if (!aliasesToRegex(aliases).test(text)) continue;
282
+ const match = text.match(aliasesToRegex(aliases));
283
+ if (!match) continue;
284
+ if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) continue;
202
285
  return TASHKENT_METRO.find((station) => station.name === canonical) || null;
203
286
  }
204
287
  return null;
@@ -13,12 +13,13 @@ function entries(rows = [], defaults = {}) {
13
13
  }));
14
14
  }
15
15
 
16
- function city({ districts = [], mahallas = [], microdistricts = [], localAreas = [], residentialComplexes = [], landmarks = [], suburbs = [] }) {
16
+ function city({ districts = [], mahallas = [], microdistricts = [], localAreas = [], developmentAreas = [], residentialComplexes = [], landmarks = [], suburbs = [] }) {
17
17
  return Object.freeze({
18
18
  ...(districts.length ? { districts: entries(districts, { entityType: 'district', country: 'UZ' }) } : {}),
19
19
  ...(mahallas.length ? { mahallas: entries(mahallas, { entityType: 'mahalla', country: 'UZ' }) } : {}),
20
20
  ...(microdistricts.length ? { microdistricts: entries(microdistricts, { entityType: 'microdistrict', country: 'UZ' }) } : {}),
21
21
  ...(localAreas.length ? { localAreas: entries(localAreas, { entityType: 'local_area', country: 'UZ' }) } : {}),
22
+ ...(developmentAreas.length ? { developmentAreas: entries(developmentAreas, { entityType: 'development_area', country: 'UZ' }) } : {}),
22
23
  ...(residentialComplexes.length ? { residentialComplexes: entries(residentialComplexes, { entityType: 'residential_complex', country: 'UZ' }) } : {}),
23
24
  ...(landmarks.length ? { landmarks: entries(landmarks, { entityType: 'poi', country: 'UZ' }) } : {}),
24
25
  ...(suburbs.length ? { suburbs: entries(suburbs, { entityType: 'suburb', country: 'UZ' }) } : {}),
@@ -57,8 +58,38 @@ const nukusMahallas = NUKUS_MFY_NAMES.map((name) => ({
57
58
 
58
59
  export const UZ_LOCATION_EXTENSIONS = Object.freeze({
59
60
  Tashkent: city({
61
+ mahallas: [
62
+ { name: 'Khastimam', aliases: ['Хастимом'], parent: 'Almazar' },
63
+ { name: 'Yangi Tashkent', aliases: ['Yangi Toshkent','Янги Тошкент','Янги Ташкент'], parent: 'Almazar' },
64
+ { name: 'Umid', aliases: ['Умид'], parent: 'Almazar' },
65
+ { name: 'Kashgar', aliases: ['Qashqar','Кашгар'], parent: 'Yunusabad' },
66
+ { name: 'Buyuk Turan', aliases: ['Buyuk Turon','Буюк Турон','Буюк Туран'], parent: 'Yunusabad' },
67
+ { name: 'Minor', aliases: ['Минор'], parent: 'Yunusabad' },
68
+ { name: 'Labzak', aliases: ['Лабзак'], parent: 'Shaykhantahur' },
69
+ { name: 'Rakat', aliases: ['Ракат'], parent: 'Yakkasaray' },
70
+ { name: 'Belaryk', aliases: ['Беларык'], parent: 'Yakkasaray' },
71
+ { name: 'Shahjahan', aliases: ['Shahjahon','Шахжахон'], parent: 'Yakkasaray' },
72
+ { name: 'Mukimiy', aliases: ['Muqimiy','Мукими','Муқимий'], parent: 'Yakkasaray' },
73
+ { name: 'Birlashgan', aliases: ['Бирлашган'], parent: 'Yashnobod' },
74
+ { name: 'Nadyra', aliases: ['Nodira','Нодира'], parent: 'Yashnobod' },
75
+ { name: 'Makhmur', aliases: ['Maxmur','Махмур'], parent: 'Yashnobod' },
76
+ { name: 'Munavvarqori', aliases: ['Munavvar Qori','Мунаввар Қори','Мунаввар Кори'], parent: 'Mirzo Ulugbek' },
77
+ { name: 'Beshkapa', aliases: ['Бешкапа'], parent: 'Mirzo Ulugbek' },
78
+ { name: 'Chashtepa', aliases: ['Чаштепа'], parent: 'Yangihayot' },
79
+ { name: 'Yangi Darhan', aliases: ['Yangi Darxon','Янги Дархан','Янги Дархон'], parent: 'Yangihayot' },
80
+ ],
60
81
  microdistricts: [
61
82
  ['Qorasuv', 'Qorasuv massivi', 'Korasuv massivi', 'Корасув Массиви', 'Корасув массиви', 'Карасу массив', 'Карасу массиви', 'Қорасув массиви'],
83
+ { name: 'Sergeli-3A', aliases: ['Sergeli 3A','Сергели-3А','Сергели 3А','Сергели 3А массив','Сергели-3А массив'], parent: 'Yangihayot' },
84
+ { name: 'Sergeli-5A', aliases: ['Sergeli 5A','Сергели-5А','Сергели 5А','Сергели 5А массив','Сергели-5А массив'], parent: 'Yangihayot' },
85
+ { name: 'Sergeli-7A', aliases: ['Sergeli 7A','Сергели-7А','Сергели 7А','Сергели 7А массив','Сергели-7А массив'], parent: 'Yangihayot' },
86
+ ],
87
+ localAreas: [
88
+ { name: 'Yangidarhan-1', aliases: ['Yangi Darhan-1','Yangi Darxon-1','Янги Дархан-1','Янги Дархон-1'], parent: 'Yangihayot' },
89
+ { name: 'Yangidarhan-2', aliases: ['Yangi Darhan-2','Yangi Darxon-2','Янги Дархан-2','Янги Дархон-2'], parent: 'Yangihayot' },
90
+ ],
91
+ developmentAreas: [
92
+ ['Tashkent City', 'Ташкент Сити', 'Toshkent City', 'Tashkent City IBC'],
62
93
  ],
63
94
  }),
64
95