@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 +2 -1
- package/package.json +169 -41
- package/src/central-asia-locations.js +101 -17
- package/src/geo.js +10 -9
- package/src/geography-display.js +1 -1
- package/src/hiring-candidate-fields.d.ts +1 -0
- package/src/hiring-candidate-fields.js +8 -0
- package/src/hiring.js +2 -2
- package/src/housing-listing-enrichment.js +3 -3
- package/src/location-merge.js +1 -0
- package/src/locations.js +69 -4
- package/src/tashkent-colloquial.js +14 -12
- package/src/tashkent-housing-geography.d.ts +10 -0
- package/src/tashkent-housing-geography.js +91 -8
- package/src/uz-location-extensions.js +32 -1
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
|
|
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
|
-
".": {
|
|
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": {
|
|
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": {
|
|
21
|
-
|
|
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": {
|
|
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": {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"./housing-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"./housing-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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": {
|
|
53
|
-
|
|
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": {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"./hiring-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"./hiring-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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": {
|
|
70
|
-
|
|
71
|
-
|
|
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": {
|
|
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": [
|
|
181
|
+
"files": [
|
|
182
|
+
"src",
|
|
183
|
+
"index.d.ts",
|
|
184
|
+
"README.md"
|
|
185
|
+
],
|
|
77
186
|
"sideEffects": false,
|
|
78
|
-
"engines": {
|
|
79
|
-
|
|
80
|
-
|
|
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": [
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
return
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
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
|
});
|
package/src/geography-display.js
CHANGED
|
@@ -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: 'Бектемир',
|
|
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 {
|
|
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 =
|
|
197
|
-
const metro =
|
|
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);
|
package/src/location-merge.js
CHANGED
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:
|
|
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({
|
|
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('
|
|
59
|
-
area('
|
|
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', '
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|