@whiteslove/parsing-lexicon 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/central-asia-locations.js +87 -3
- package/src/central-asia.js +26 -2
- package/src/geo.js +7 -7
- package/src/geography-display.js +1 -1
- package/src/geography-zone-display.js +1 -1
- package/src/geography.js +13 -4
- package/src/hiring-requirements.js +5 -5
- package/src/hiring-salary-context.js +6 -2
- package/src/hiring-temporal.js +4 -0
- package/src/housing-address.js +1 -1
- package/src/housing-card-fields.js +20 -4
- package/src/housing-display.js +14 -1
- package/src/housing-listing-enrichment.js +53 -10
- package/src/housing-money.js +1 -1
- package/src/housing-poi-extensions.js +6 -0
- package/src/housing-source-aliases.js +3 -0
- package/src/housing-structured.js +18 -1
- package/src/index.js +2 -0
- package/src/kg-location-extensions.js +91 -11
- package/src/kz-district-extensions.js +36 -0
- package/src/kz-location-extensions.js +3 -3
- package/src/locations-runtime.js +10 -0
- package/src/locations.js +127 -39
- package/src/money-core.js +1 -1
- package/src/money-lexicon.js +1 -1
- package/src/money.js +16 -1
- package/src/normalization.js +39 -4
- package/src/tashkent-colloquial.js +8 -1
- package/src/tashkent-gastronomic-streets.js +108 -0
- package/src/tashkent-pois.js +38 -18
- package/src/ua-city-location-expansions.js +96 -0
- package/src/ua-geo-coordinates.d.ts +14 -0
- package/src/ua-geo-coordinates.js +209 -0
- package/src/ua-geo-resolver.d.ts +31 -0
- package/src/ua-geo-resolver.js +171 -0
- package/src/ua-location-extensions-metro.js +42 -1
- package/src/ua-location-extensions-regional.js +2 -2
- package/src/ua-secondary-cities.js +6 -4
- package/src/uz-geo-coordinates.d.ts +14 -0
- package/src/uz-geo-coordinates.js +166 -0
- package/src/uz-tashkent-context-extensions.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whiteslove/parsing-lexicon",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
},
|
|
38
38
|
"./central-asia": "./src/central-asia.js",
|
|
39
39
|
"./ukraine": "./src/ukraine.js",
|
|
40
|
+
"./ua-geo-coordinates": { "types": "./src/ua-geo-coordinates.d.ts", "import": "./src/ua-geo-coordinates.js" },
|
|
41
|
+
"./ua-geo-resolver": { "types": "./src/ua-geo-resolver.d.ts", "import": "./src/ua-geo-resolver.js" },
|
|
42
|
+
"./uz-geo-coordinates": { "types": "./src/uz-geo-coordinates.d.ts", "import": "./src/uz-geo-coordinates.js" },
|
|
40
43
|
"./location-merge": "./src/location-merge.js",
|
|
41
44
|
"./locations": "./src/locations-runtime.js",
|
|
42
45
|
"./kg-location-extensions": "./src/kg-location-extensions.js",
|
|
@@ -50,6 +53,7 @@
|
|
|
50
53
|
"./tashkent-colloquial": "./src/tashkent-colloquial.js",
|
|
51
54
|
"./tashkent-residential-complexes": "./src/tashkent-residential-complexes.js",
|
|
52
55
|
"./tashkent-pois": "./src/tashkent-pois.js",
|
|
56
|
+
"./tashkent-gastronomic-streets": "./src/tashkent-gastronomic-streets.js",
|
|
53
57
|
"./tashkent-housing-geography": {
|
|
54
58
|
"types": "./src/tashkent-housing-geography.d.ts",
|
|
55
59
|
"import": "./src/tashkent-housing-geography.js"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LOCATION_DICTIONARIES } from './locations.js';
|
|
1
|
+
import { LOCATION_DICTIONARIES } from './locations-runtime.js';
|
|
2
2
|
import { LOCATION_LIST_KEYS } from './location-merge.js';
|
|
3
3
|
import { CITIES_BY_COUNTRY, canonicalCity } from './geography.js';
|
|
4
4
|
import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
|
|
@@ -6,7 +6,7 @@ import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_SEARCH_CLUSTERS } from './kz-location-exte
|
|
|
6
6
|
import { UZ_AMBIGUOUS_LOCAL_NAMES } from './uz-location-extensions.js';
|
|
7
7
|
|
|
8
8
|
// Compatibility names retained for consumers. The canonical dictionaries are
|
|
9
|
-
// assembled once in
|
|
9
|
+
// assembled once in the public runtime registry; matcher layers must consume, not re-merge.
|
|
10
10
|
export const KZ_EXPANDED_LOCATION_DICTIONARIES = LOCATION_DICTIONARIES.KZ || Object.freeze({});
|
|
11
11
|
export const UZ_EXPANDED_LOCATION_DICTIONARIES = LOCATION_DICTIONARIES.UZ || Object.freeze({});
|
|
12
12
|
|
|
@@ -139,6 +139,86 @@ function semanticBounds(value, match) {
|
|
|
139
139
|
return { start, end };
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
const NUMBERED_LOCAL_AREA_NAME_RE = /(?:^|[-–—/\s])\d{1,2}[aа]?(?=$|[^\p{L}\p{N}_])/iu;
|
|
143
|
+
const NUMBERED_CHILD_SUFFIX_RE = /^\s*[-–—/]\s*\d{1,2}[aа]?(?=$|[^\p{L}\p{N}_])/iu;
|
|
144
|
+
|
|
145
|
+
function isUmbrellaAreaPrefixOfNumberedBlock(value, candidate) {
|
|
146
|
+
if (candidate.type !== 'local_area') return false;
|
|
147
|
+
if (NUMBERED_LOCAL_AREA_NAME_RE.test(String(candidate.name || ''))) return false;
|
|
148
|
+
return NUMBERED_CHILD_SUFFIX_RE.test(value.slice(candidate.end));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const RESIDENTIAL_MARKER_TEXT = String.raw`(?:ж\.?\s*к\.?|жил(?:ой|ого)?\s+комплекс\p{L}*|residential\s+complex|turar\s+joy\s+majmuasi|tjm)`;
|
|
152
|
+
const residentialMarkedAliasCache = new WeakMap();
|
|
153
|
+
|
|
154
|
+
function stripResidentialMarker(value) {
|
|
155
|
+
return String(value || '')
|
|
156
|
+
.replace(new RegExp(`^\\s*${RESIDENTIAL_MARKER_TEXT}\\s*[:\\-–—]?\\s*`, 'iu'), '')
|
|
157
|
+
.replace(new RegExp(`\\s*${RESIDENTIAL_MARKER_TEXT}\\s*$`, 'iu'), '')
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function residentialMarkedAliasRegex(item) {
|
|
162
|
+
if (residentialMarkedAliasCache.has(item)) return residentialMarkedAliasCache.get(item);
|
|
163
|
+
const aliases = [...new Set([item?.name, ...(item?.aliases || [])]
|
|
164
|
+
.map(stripResidentialMarker)
|
|
165
|
+
.filter(Boolean))];
|
|
166
|
+
const re = aliases.length ? aliasesToRegex(aliases) : null;
|
|
167
|
+
residentialMarkedAliasCache.set(item, re);
|
|
168
|
+
return re;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function markedResidentialMatch(value, item) {
|
|
172
|
+
const aliasRe = residentialMarkedAliasRegex(item);
|
|
173
|
+
if (!aliasRe) return null;
|
|
174
|
+
const markerRe = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${RESIDENTIAL_MARKER_TEXT}\\s*[:\\-–—]?\\s*`, 'giu');
|
|
175
|
+
for (const marker of value.matchAll(markerRe)) {
|
|
176
|
+
const tailStart = (marker.index ?? 0) + marker[0].length;
|
|
177
|
+
const tail = value.slice(tailStart, Math.min(value.length, tailStart + 96));
|
|
178
|
+
const aliasMatch = tail.match(aliasRe);
|
|
179
|
+
if (!aliasMatch) continue;
|
|
180
|
+
const offset = aliasMatch.index ?? 0;
|
|
181
|
+
if (offset > 0 && /\S/u.test(tail.slice(0, offset))) continue;
|
|
182
|
+
return { 0: aliasMatch[0], index: tailStart + offset };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const LOCATION_CLAUSE_BOUNDARY_RE = /[\n\r.!?;|]/u;
|
|
188
|
+
const PRIMARY_LOCATION_PREFIX_RE = /(?:(?:^|[^\p{L}\p{N}_])(?:адрес|локаци(?:я|и)|расположени(?:е|я)|находится)\s*[:\-–—]?\s*|(?:^|[^\p{L}\p{N}_])(?:в|на)\s+(?:(?:ж\.?\s*к\.?|жил(?:ом|ой)\s+комплекс\p{L}*|микрорайон\p{L}*|мкр\.?|массив\p{L}*|махалл\p{L}*|mahalla|massiv|district|район\p{L}*|metro|метро)\s*)?)$/iu;
|
|
189
|
+
const NEARBY_LOCATION_PREFIX_RE = /(?:^|[^\p{L}\p{N}_])(?:до|около|возле|рядом(?:\s+(?:с|со))?|недалеко\s+от|неподал[её]ку\s+от|вблизи|напротив|ориентир(?:ом)?|near|nearby|close\s+to|next\s+to|opposite|landmark|yaqin(?:ida)?|yonida|mo['’ʻ]?ljal|яқин(?:ида)?|ёнида|мўлжал|жақын|жанында|маңында)(?=$|[^\p{L}\p{N}_])[\s\S]{0,96}$/iu;
|
|
190
|
+
const DISTANCE_LOCATION_PREFIX_RE = /(?:\d+(?:[.,]\d+)?\s*(?:мин(?:ут\p{L}*)?|min(?:ute)?s?|daqiqa|м|m|метр\p{L}*|км|km)\s*(?:от|до|from|to)\s+)[\s\S]{0,80}$/iu;
|
|
191
|
+
const NEARBY_LOCATION_SUFFIX_RE = /^\s*(?:[-—–:]\s*)?(?:(?:\d+(?:[.,]\d+)?\s*(?:мин(?:ут\p{L}*)?|min(?:ute)?s?|daqiqa|м|m|метр\p{L}*|км|km))|(?:рядом|поблизости|nearby|yaqin(?:ida)?|яқин(?:ида)?|жақын))(?=$|[^\p{L}\p{N}_])/iu;
|
|
192
|
+
|
|
193
|
+
function clauseStart(value, start) {
|
|
194
|
+
for (let index = start - 1; index >= 0; index -= 1) {
|
|
195
|
+
if (LOCATION_CLAUSE_BOUNDARY_RE.test(value[index])) return index + 1;
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clauseEnd(value, end) {
|
|
201
|
+
for (let index = end; index < value.length; index += 1) {
|
|
202
|
+
if (LOCATION_CLAUSE_BOUNDARY_RE.test(value[index])) return index;
|
|
203
|
+
}
|
|
204
|
+
return value.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function locationMentionRole(value, candidate) {
|
|
208
|
+
const start = clauseStart(value, candidate.start);
|
|
209
|
+
const end = clauseEnd(value, candidate.end);
|
|
210
|
+
const before = value.slice(start, candidate.start);
|
|
211
|
+
const after = value.slice(candidate.end, end);
|
|
212
|
+
|
|
213
|
+
// A direct subject marker later in the same clause wins over an unrelated
|
|
214
|
+
// earlier temporal "до": "до пятницы квартира в ЖК X" is still ЖК X itself.
|
|
215
|
+
if (PRIMARY_LOCATION_PREFIX_RE.test(before)) return 'primary';
|
|
216
|
+
if (NEARBY_LOCATION_PREFIX_RE.test(before) || DISTANCE_LOCATION_PREFIX_RE.test(before) || NEARBY_LOCATION_SUFFIX_RE.test(after)) {
|
|
217
|
+
return 'nearby';
|
|
218
|
+
}
|
|
219
|
+
return 'mentioned';
|
|
220
|
+
}
|
|
221
|
+
|
|
142
222
|
function publicMatch(candidate) {
|
|
143
223
|
const { start, end, matchedText, explicitContext, ...result } = candidate;
|
|
144
224
|
return Object.freeze(result);
|
|
@@ -149,7 +229,8 @@ function findEntryMatches(text, cityName, data) {
|
|
|
149
229
|
const raw = [];
|
|
150
230
|
for (const key of LOCATION_LIST_KEYS) {
|
|
151
231
|
for (const item of data?.[key] || []) {
|
|
152
|
-
const match = value.match(item?.re)
|
|
232
|
+
const match = value.match(item?.re)
|
|
233
|
+
|| (key === 'residentialComplexes' ? markedResidentialMatch(value, item) : null);
|
|
153
234
|
if (!match) continue;
|
|
154
235
|
const { start, end } = semanticBounds(value, match);
|
|
155
236
|
const candidate = {
|
|
@@ -167,8 +248,11 @@ function findEntryMatches(text, cityName, data) {
|
|
|
167
248
|
end,
|
|
168
249
|
matchedText: value.slice(start, end),
|
|
169
250
|
explicitContext: false,
|
|
251
|
+
role: 'mentioned',
|
|
170
252
|
};
|
|
253
|
+
if (isUmbrellaAreaPrefixOfNumberedBlock(value, candidate)) continue;
|
|
171
254
|
candidate.explicitContext = hasExplicitContext(value, candidate);
|
|
255
|
+
candidate.role = locationMentionRole(value, candidate);
|
|
172
256
|
raw.push(candidate);
|
|
173
257
|
}
|
|
174
258
|
}
|
package/src/central-asia.js
CHANGED
|
@@ -51,6 +51,9 @@ export const UZ_SEARCH_TARGETS = Object.freeze([
|
|
|
51
51
|
city("Ellikqal'a", { uzLatn: ["Ellikqal'a", 'Ellikkala'], kaaLat: ['Elliqala'], ru: ['Элликкала'], en: ['Ellikkala'] }, { country: 'UZ', type: 'search_target', priority: 'P4', region: "Qoraqalpog'iston" }),
|
|
52
52
|
]);
|
|
53
53
|
|
|
54
|
+
/** Canonical Kyrgyzstan city catalog used by geography consumers. */
|
|
55
|
+
export const KG_CITY_CATALOG = CITIES_BY_COUNTRY.KG;
|
|
56
|
+
|
|
54
57
|
export const KZ_LOCATION_TERMS = Object.freeze({
|
|
55
58
|
microdistrict: Object.freeze(['микрорайон', 'микр.', 'микр', 'мкр.', 'мкр', 'м-н', 'мкр-н', 'ықшамаудан', 'ықш.ауд.', 'ықш']),
|
|
56
59
|
residentialArea: Object.freeze(['жилой массив', 'жилмассив', 'ж/м', 'ж.м.', 'тұрғын алабы', 'тұрғын массиві', 'массив']),
|
|
@@ -66,6 +69,14 @@ export const UZ_LOCATION_TERMS = Object.freeze({
|
|
|
66
69
|
residentialComplex: Object.freeze(['ЖК', 'жк', 'turar joy majmuasi', 'residential complex', 'residence']),
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
export const KG_LOCATION_TERMS = Object.freeze({
|
|
73
|
+
city: Object.freeze(['шаар', 'шаары', 'город', 'г.', 'city']),
|
|
74
|
+
district: Object.freeze(['район', 'району', 'р-н', 'district']),
|
|
75
|
+
microdistrict: Object.freeze(['микрорайон', 'мкр', 'мкр.', 'кичирайон', 'кичи район']),
|
|
76
|
+
residentialArea: Object.freeze(['жилмассив', 'жилой массив', 'массив', 'конуш', 'жаңы конуш']),
|
|
77
|
+
residentialComplex: Object.freeze(['ЖК', 'жк', 'жилой комплекс', 'турак жай комплекси', 'residential complex', 'residence']),
|
|
78
|
+
});
|
|
79
|
+
|
|
69
80
|
export function canonicalKazakhstanCity(value) {
|
|
70
81
|
return canonicalCity(value, 'KZ');
|
|
71
82
|
}
|
|
@@ -74,14 +85,27 @@ export function canonicalUzbekistanCity(value) {
|
|
|
74
85
|
return canonicalCity(value, 'UZ');
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
export function canonicalKyrgyzstanCity(value) {
|
|
89
|
+
return canonicalCity(value, 'KG');
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export function canonicalCentralAsiaCity(value, country = null) {
|
|
78
93
|
if (country === 'KZ') return canonicalKazakhstanCity(value);
|
|
79
94
|
if (country === 'UZ') return canonicalUzbekistanCity(value);
|
|
80
|
-
|
|
95
|
+
if (country === 'KG') return canonicalKyrgyzstanCity(value);
|
|
96
|
+
return canonicalKazakhstanCity(value)
|
|
97
|
+
|| canonicalUzbekistanCity(value)
|
|
98
|
+
|| canonicalKyrgyzstanCity(value);
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
export function centralAsiaCityAliases(canonical, country) {
|
|
84
|
-
const catalog = country === 'KZ'
|
|
102
|
+
const catalog = country === 'KZ'
|
|
103
|
+
? KZ_CITY_CATALOG
|
|
104
|
+
: country === 'UZ'
|
|
105
|
+
? UZ_CITY_CATALOG
|
|
106
|
+
: country === 'KG'
|
|
107
|
+
? KG_CITY_CATALOG
|
|
108
|
+
: [...KZ_CITY_CATALOG, ...UZ_CITY_CATALOG, ...KG_CITY_CATALOG];
|
|
85
109
|
const item = catalog.find((entry) => entry.canonical === canonical);
|
|
86
110
|
return item ? Object.freeze([...new Set([item.canonical, ...aliasesOf(item)])]) : Object.freeze([]);
|
|
87
111
|
}
|
package/src/geo.js
CHANGED
|
@@ -48,7 +48,7 @@ export const TASHKENT_METRO = Object.freeze([
|
|
|
48
48
|
station('Hamid Olimjon', 'Хамид Алимджан', 'Hamid Olimjon', 'chilonzor', ['Хамид Олимжон', 'Hamid Alimjan', 'Hamid Alimjon']),
|
|
49
49
|
station('Amir Temur Xiyoboni', 'Амир Темур Хиёбони', 'Amir Temur Xiyoboni', 'chilonzor', ['Сквер', 'Amir Timur Square', 'Amir Temur']),
|
|
50
50
|
station('Mustaqillik Maydoni', 'Мустакиллик майдони', 'Mustaqillik Maydoni', 'chilonzor', ['Мустақиллик майдони', 'Площадь Независимости', 'Independence Square']),
|
|
51
|
-
station('Paxtakor', 'Пахтакор', 'Paxtakor', 'chilonzor'
|
|
51
|
+
station('Paxtakor', 'Пахтакор', 'Paxtakor', 'chilonzor'),
|
|
52
52
|
station('Xalqlar Dostligi', 'Халклар Дустлиги', 'Xalqlar Dostligi', 'chilonzor', ["Xalqlar Do'stligi", 'Xalqlar Do‘stligi', 'Xalqlar Doʻstligi', 'Халклар Дўстлиги', 'Халқлар Дўстлиги', 'Дружба народов', 'Bunyodkor', 'Бунёдкор']),
|
|
53
53
|
station('Milliy Bog', 'Миллий Бог', 'Milliy Bog', 'chilonzor', ["Milliy Bog'", 'Milliy Bog‘', 'Milliy Bogʻ', 'Миллий Боғ', 'Yoshlik', 'Ёшлик']),
|
|
54
54
|
station('Novza', 'Новза', 'Novza', 'chilonzor', ['Хамза', 'Hamza', 'Hamza metro', 'метро Хамза']),
|
|
@@ -123,9 +123,9 @@ export const TASHKENT_AREAS = Object.freeze({
|
|
|
123
123
|
area('Karakamysh-2/4', ['каракамыш 2 4', 'қорақамиш 2 4', 'qoraqamish 2 4', 'karakamish 2 4']),
|
|
124
124
|
area('Karakamysh-2/5', ['каракамыш 2 5', 'қорақамиш 2 5', 'qoraqamish 2 5', 'karakamish 2 5']),
|
|
125
125
|
area('Olympia', ['олимпия', 'olimpiya', 'olympia']), area('Vuzgorodok', ['вузгородок', 'вуз городок', 'vuzgorodok']),
|
|
126
|
-
area('Medgorodok', ['медгородок', 'мед городок', 'medgorodok']),
|
|
127
|
-
area('Chimbay', ['чимбай', 'chimboy', 'chimbay']),
|
|
128
|
-
area('
|
|
126
|
+
area('Medgorodok', ['медгородок', 'мед городок', 'medgorodok', 'TashMI-2', 'TashMI 2', 'ToshMI-2', 'ToshMI 2', 'ТашМИ-2', 'ТашМИ 2', 'ТошМИ-2', 'ТошМИ 2', 'новый ташми', 'новый ташми-2', 'янги ташми', 'yangi tashmi']),
|
|
127
|
+
area('Chimbay', ['чимбай', 'чимбой', 'chimboy', 'chimbay']),
|
|
128
|
+
area('Taxtapul', ['тахтапуль', 'тахтапул', 'taxtapul', 'takhtapul', 'Takhtapul']),
|
|
129
129
|
]),
|
|
130
130
|
Mirobod: Object.freeze([
|
|
131
131
|
area('Hospitalny', ['госпитальный', 'госпиталка', 'hospitalny']),
|
|
@@ -140,13 +140,13 @@ export const TASHKENT_AREAS = Object.freeze({
|
|
|
140
140
|
area('Traktorsozlar-2', ['тракторсозлар 2', 'traktorsozlar 2', 'ттз 2', 'ttz 2']),
|
|
141
141
|
area('TTZ-3', ['ттз 3', 'ttz 3']),
|
|
142
142
|
area('Traktorsozlar-4', ['тракторсозлар 4', 'traktorsozlar 4', 'ттз 4', 'ttz 4']),
|
|
143
|
-
area('Yalangach', ['ялангач', 'yalangach', "yalang'och"]), area('Feruza', ['феруза', 'feruza']), area('Geofizika', ['геофизика', 'поселок геофизиков', 'geofizika']),
|
|
143
|
+
area('Yalangach', ['ялангач', 'ялангач массив', 'yalangach', "yalang'och", "yalang'och dahasi", 'высоковольтный', 'массив высоковольтный']), area('Feruza', ['феруза', 'feruza']), area('Geofizika', ['геофизика', 'поселок геофизиков', 'geofizika']),
|
|
144
144
|
]),
|
|
145
145
|
Uchtepa: Object.freeze([
|
|
146
146
|
area('Lolazor', ['лолазор', 'lolazor']),
|
|
147
147
|
]),
|
|
148
148
|
Sergeli: Object.freeze([
|
|
149
|
-
area('Yangi Sergeli', ['янги сергели', 'yangi sergeli']),
|
|
149
|
+
area('Yangi Sergeli', ['янги сергели', 'yangi sergeli']),
|
|
150
150
|
area('Uzgarish', ['узгарыш', 'ўзгариш', "o'zgarish", 'uzgarish']),
|
|
151
151
|
]),
|
|
152
152
|
Chilanzar: Object.freeze([
|
|
@@ -176,4 +176,4 @@ export const TASHKENT_AREAS = Object.freeze({
|
|
|
176
176
|
...[1, 2, 3, 4].map((n) => area(`Tuzel-${n}`, [`тузель ${n}`, `tuzel ${n}`])),
|
|
177
177
|
area('Tashselmash', ['ташсельмаш', 'tashselmash']), area('Alimkent', ['алимкент', 'olimkent', 'alimkent']), area('Shohimardon', ['шохимардон', 'shohimardon']),
|
|
178
178
|
]),
|
|
179
|
-
});
|
|
179
|
+
});
|
package/src/geography-display.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
|
|
2
2
|
import { CITIES, REGIONS } from './geography.js';
|
|
3
|
-
import { dictionaryFor } from './locations.js';
|
|
3
|
+
import { dictionaryFor } from './locations-runtime.js';
|
|
4
4
|
import { aliasesOf } from './normalization.js';
|
|
5
5
|
|
|
6
6
|
// Centralized presentation names for canonical geography values.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { geographyDisplayName } from './geography-display.js';
|
|
2
|
-
import { dictionaryFor } from './locations.js';
|
|
2
|
+
import { dictionaryFor } from './locations-runtime.js';
|
|
3
3
|
import { aliasesOf, findCanonical } from './normalization.js';
|
|
4
4
|
import { canonicalCountryCode } from './countries.js';
|
|
5
5
|
|
package/src/geography.js
CHANGED
|
@@ -57,10 +57,19 @@ const RO_CITIES_BASE = Object.freeze([
|
|
|
57
57
|
export const RO_CITIES = Object.freeze([...RO_CITIES_BASE, ...RO_CITY_EXTENSIONS]);
|
|
58
58
|
|
|
59
59
|
export const KG_CITIES = Object.freeze([
|
|
60
|
-
entity('Bishkek', { ru: ['Бишкек'], en: ['Bishkek'], kk: ['Бішкек'], uzLatn: ['Bishkek'] }, { country: 'KG', type: 'city' }),
|
|
61
|
-
entity('Osh', { ru: ['Ош'], en: ['Osh'], kk: ['Ош'], uzLatn: ['Osh'] }, { country: 'KG', type: 'city' }),
|
|
62
|
-
entity('
|
|
63
|
-
|
|
60
|
+
entity('Bishkek', { ky: ['Бишкек'], ru: ['Бишкек'], en: ['Bishkek'], kk: ['Бішкек'], uzLatn: ['Bishkek'] }, { country: 'KG', type: 'city' }),
|
|
61
|
+
entity('Osh', { ky: ['Ош'], ru: ['Ош'], en: ['Osh'], kk: ['Ош'], uzLatn: ['Osh'] }, { country: 'KG', type: 'city' }),
|
|
62
|
+
entity('Jalal-Abad', { ky: ['Жалал-Абад', 'Жалал Абад'], ru: ['Джалал-Абад', 'Джалал Абад'], en: ['Jalal-Abad', 'Jalalabad'] }, { country: 'KG', type: 'city' }),
|
|
63
|
+
entity('Karakol', { ky: ['Каракол'], ru: ['Каракол', 'Пржевальск'], en: ['Karakol', 'Przhevalsk'], kk: ['Қаракөл'], uzLatn: ['Karakol'] }, { country: 'KG', type: 'city' }),
|
|
64
|
+
entity('Tokmok', { ky: ['Токмок'], ru: ['Токмок'], en: ['Tokmok'] }, { country: 'KG', type: 'city' }),
|
|
65
|
+
entity('Naryn', { ky: ['Нарын'], ru: ['Нарын'], en: ['Naryn'] }, { country: 'KG', type: 'city' }),
|
|
66
|
+
entity('Talas', { ky: ['Талас'], ru: ['Талас'], en: ['Talas'] }, { country: 'KG', type: 'city' }),
|
|
67
|
+
entity('Batken', { ky: ['Баткен'], ru: ['Баткен'], en: ['Batken'] }, { country: 'KG', type: 'city' }),
|
|
68
|
+
entity('Kara-Balta', { ky: ['Кара-Балта', 'Кара Балта'], ru: ['Кара-Балта', 'Кара Балта'], en: ['Kara-Balta', 'Kara Balta'] }, { country: 'KG', type: 'city' }),
|
|
69
|
+
entity('Balykchy', { ky: ['Балыкчы'], ru: ['Балыкчи'], en: ['Balykchy', 'Balykchi'] }, { country: 'KG', type: 'city' }),
|
|
70
|
+
entity('Kant', { ky: ['Кант'], ru: ['Кант'], en: ['Kant'] }, { country: 'KG', type: 'city' }),
|
|
71
|
+
entity('Uzgen', { ky: ['Өзгөн', 'Озгон'], ru: ['Узген', 'Озгон'], en: ['Uzgen', 'Özgön', 'Ozgon'] }, { country: 'KG', type: 'city' }),
|
|
72
|
+
entity('Kyzyl-Kiya', { ky: ['Кызыл-Кыя', 'Кызыл Кыя'], ru: ['Кызыл-Кия', 'Кызыл Кия'], en: ['Kyzyl-Kiya', 'Kyzyl Kiya'] }, { country: 'KG', type: 'city' }),
|
|
64
73
|
]);
|
|
65
74
|
|
|
66
75
|
/** Canonical cities used across global hiring and housing ingestion. */
|
|
@@ -21,12 +21,12 @@ export function detectHiringSeniority(value) {
|
|
|
21
21
|
['director', /\b(?:engineering\s+)?director\b|директор/i],
|
|
22
22
|
['principal', /\bprincipal\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
23
23
|
['staff', /\bstaff\b(?=[^,;\n]{0,60}\b(?:engineer|developer|architect)\b)/i],
|
|
24
|
-
['lead', /\b(?:team\s*lead|tech\s*lead|lead\s+(?:engineer|developer|frontend|backend))\b|тимлид|техлид|ведущ\w
|
|
24
|
+
['lead', /\b(?:team\s*lead|tech\s*lead|lead\s+(?:engineer|developer|frontend|backend))\b|тимлид|техлид|ведущ\w*|yetakchi\s+(?:dasturchi|mutaxassis|muhandis)|жетекші\s+(?:бағдарламашы|маман|инженер)/iu],
|
|
25
25
|
['manager', /\bengineering manager\b|руководител/i],
|
|
26
|
-
['senior', /\bsenior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|сеньор|старш\w*\s+(?:разработ|инженер)/
|
|
27
|
-
['middle', /\b(?:middle|mid[- ]?level)\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)
|
|
28
|
-
['junior', /\bjunior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|джун\w*|младш\w
|
|
29
|
-
['intern', /\b(?:intern|trainee)\b|стаж[ёе]
|
|
26
|
+
['senior', /\bsenior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|сеньор|старш\w*\s+(?:разработ|инженер)|katta\s+(?:dasturchi|mutaxassis|muhandis)|аға\s+(?:бағдарламашы|маман|инженер)/iu],
|
|
27
|
+
['middle', /\b(?:middle|mid[- ]?level)\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|мидл|o['’ʻ]?rta\s+(?:dasturchi|mutaxassis|muhandis)|орта\s+(?:бағдарламашы|маман|инженер)/iu],
|
|
28
|
+
['junior', /\bjunior\b(?=[^,;\n]{0,60}\b(?:engineer|developer|frontend|backend|software)\b)|джун\w*|младш\w*|kichik\s+(?:dasturchi|mutaxassis|muhandis)|кіші\s+(?:бағдарламашы|маман|инженер)/iu],
|
|
29
|
+
['intern', /\b(?:intern|trainee)\b|стаж[ёе]р|стажир|amaliyotchi|stajyor|тәжірибеден\s+өтуші|стажер/iu],
|
|
30
30
|
];
|
|
31
31
|
return checks.find(([, pattern]) => pattern.test(text))?.[0] || null;
|
|
32
32
|
}
|
|
@@ -111,7 +111,11 @@ export function parseHiringSalaryWithContext(value, options = {}) {
|
|
|
111
111
|
return withContext(parseSalary(value), value, options);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Include short local-board labels/markers used in real vacancy feeds: `З/п`,
|
|
115
|
+
// `ЗП`, and the money emoji that ish-bor-style cards use before a salary field.
|
|
116
|
+
// The marker is still required for long prose so unrelated large numbers are not
|
|
117
|
+
// promoted to compensation merely because the vacancy happens to be in UZ/KZ.
|
|
118
|
+
const VACANCY_COMPENSATION_RE = /(?:💵|💰|salary|salary\s+range|base\s+pay|pay\s+range|annual\s+pay|compensation(?:\s+range)?|з\s*[/\\.\-]?\s*п\b|заработн\p{L}*\s+плат\p{L}*|зарплат\p{L}*|оклад\p{L}*|вилка\s+оплат\p{L}*|оплата\s+труда|компенсац\p{L}*|ставка)/giu;
|
|
115
119
|
|
|
116
120
|
// AI-recruiting/staffing postings (e.g. Mercor) routinely mention funding,
|
|
117
121
|
// valuation or revenue figures in the same listing as the actual salary. A
|
|
@@ -141,4 +145,4 @@ export function parseHiringVacancySalary(value, options = {}) {
|
|
|
141
145
|
if (parsed && (parsed.min != null || parsed.max != null)) return parsed;
|
|
142
146
|
}
|
|
143
147
|
return null;
|
|
144
|
-
}
|
|
148
|
+
}
|
package/src/hiring-temporal.js
CHANGED
|
@@ -9,6 +9,10 @@ export const HIRING_MONTHS = Object.freeze({
|
|
|
9
9
|
iulie: 6, august: 7, septembrie: 8, octombrie: 9, noiembrie: 10, decembrie: 11,
|
|
10
10
|
січня: 0, лютого: 1, березня: 2, квітня: 3, травня: 4, червня: 5,
|
|
11
11
|
липня: 6, серпня: 7, вересня: 8, жовтня: 9, листопада: 10, грудня: 11,
|
|
12
|
+
yanvar: 0, fevral: 1, mart: 2, aprel: 3, iyun: 5,
|
|
13
|
+
iyul: 6, avgust: 7, sentabr: 8, oktabr: 9, noyabr: 10, dekabr: 11,
|
|
14
|
+
қаңтар: 0, ақпан: 1, наурыз: 2, сәуір: 3, мамыр: 4, маусым: 5,
|
|
15
|
+
шілде: 6, тамыз: 7, қыркүйек: 8, қазан: 9, қараша: 10, желтоқсан: 11,
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
export const UNICODE_LEFT_BOUNDARY = '(?<![\\p{L}\\p{N}])';
|
package/src/housing-address.js
CHANGED
|
@@ -8,7 +8,7 @@ const ADDRESS_LABEL_RE = /(?:адрес|адреса|адресація|адре
|
|
|
8
8
|
const PREFIX_STREET_MARKER = String.raw`(?:(?:ул(?:ица)?|вул(?:иця)?|пр|просп(?:ект)?|пр-т|переул(?:ок)?|пров(?:улок)?|проезд|наб(?:ережная)?|шоссе|str(?:ada)?|street|st|avenue|ave|road|rd|көше)\.?)`;
|
|
9
9
|
const POSTFIX_STREET_MARKER = String.raw`(?:ko['’ʼ\u02bc]?cha(?:si)?|кўча(?:си)?|коча(?:си)?|kocha(?:si)?)`;
|
|
10
10
|
const POSTFIX_STREET_TYPE = String.raw`(?:вулиця|улица|провулок|переулок|проспект|бульвар|набережна|набережная|шосе|шоссе|площа|площадь|узвіз|спуск|алея|аллея|дорога|тупик)`;
|
|
11
|
-
const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.|uy
|
|
11
|
+
const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.|uy|уй|үй|nr\.?|no\.?|№)`;
|
|
12
12
|
const BUILDING_MARKER = String.raw`(?:корп(?:ус)?\.?|к\.|строен(?:ие)?|стр\.|будова|секц(?:ия|ія)?|bloc|corp|building|bldg\.?|korpus)`;
|
|
13
13
|
const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}])?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}])?)?`;
|
|
14
14
|
const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
|
|
@@ -39,6 +39,7 @@ const PLACE_KINDS = Object.freeze([
|
|
|
39
39
|
const NEARBY_BLOCK_RE = /(?:^|\n)[^\n]{0,30}?(?:что\s+(?:есть|находится)\s+р[яa]дом|р[яa]дом(?:\s+(?:есть|находятся|расположены|с\s+домом))?|непода[лл][её]ку|поблизости|в\s+шаговой\s+доступности|окружени[ие]|ориентир(?:ы|ами)?|yaqinida|atrofida|yonida|nearby|landmarks?)\s*[:—-]?\s*([^\n]{4,500})/iu;
|
|
40
40
|
const NEARBY_NOISE_RE = /^(?:и|а|в|на|у|до|от|все|вс[её]|есть|рядом|близко|недалеко|минут\p{L}*|пешком|транспорт|остановк\p{L}*|магазин|магазины|аптека|аптеки|садик|садики|всё\s+необходимое|развит\p{L}*)$/iu;
|
|
41
41
|
const NEARBY_FILLER_RE = /(?:инфраструктур\p{L}*|все\s+необходимо|вс[её]\s+рядом|шаговой\s+доступност|развитый\s+район)/iu;
|
|
42
|
+
const QUARTER_NEARBY_PREFIX_RE = /(?:^|[\s,;:—–-])(?:до|рядом\s+(?:с|со)|недалеко\s+от|возле|около|ориентир\s*[:—–-]?|near(?:by)?|close\s+to|lângă|aproape\s+de)\s*$/iu;
|
|
42
43
|
|
|
43
44
|
function enumeratedNearby(text) {
|
|
44
45
|
const blocks = [...text.matchAll(new RegExp(NEARBY_BLOCK_RE.source, `${NEARBY_BLOCK_RE.flags}g`))]
|
|
@@ -61,18 +62,33 @@ function enumeratedNearby(text) {
|
|
|
61
62
|
return items;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function isNearbyQuarterReference(text, match) {
|
|
66
|
+
const start = match?.index ?? -1;
|
|
67
|
+
if (start < 0) return false;
|
|
68
|
+
const before = text.slice(Math.max(0, start - 64), start);
|
|
69
|
+
return QUARTER_NEARBY_PREFIX_RE.test(before);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function firstPrimaryQuarterMatch(text, pattern) {
|
|
73
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
74
|
+
for (const match of text.matchAll(new RegExp(pattern.source, flags))) {
|
|
75
|
+
if (!isNearbyQuarterReference(text, match)) return match;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
export function parseHousingQuarterLabel(value) {
|
|
65
81
|
const text = String(value || '');
|
|
66
82
|
if (!text) return null;
|
|
67
83
|
if (/(?:^|[^\p{L}\p{N}_])(?:глинк[аи]?|glinka)(?:$|[^\p{L}\p{N}_])/iu.test(text)) return 'Glinka';
|
|
68
|
-
const match = text
|
|
69
|
-
|| text
|
|
70
|
-
|| text
|
|
84
|
+
const match = firstPrimaryQuarterMatch(text, /(\d{1,3})\s*(?:-?\s*(?:chi|чи))?\s*[-\s]?\s*(?:квартал|кв-?л(?=$|[^\p{L}\p{N}_])|kvartal(?:i)?|квартил|kvartil|мкр(?=$|[^\p{L}\p{N}_])|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)/iu)
|
|
85
|
+
|| firstPrimaryQuarterMatch(text, /(?:квартал|kvartal(?:i)?|квартил|kvartil|мкр|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)\s*[-№#]?\s*(\d{1,3})/iu)
|
|
86
|
+
|| firstPrimaryQuarterMatch(text, /(?:чиланзар|chilonzor|chilanzar)\s*[-№#]?\s*(\d{1,2})(?!\d)/iu);
|
|
71
87
|
if (match) {
|
|
72
88
|
const label = `${Number(match[1])} kvartal`;
|
|
73
89
|
return STREET_LIKE_PLACE_RE.test(label) ? null : label;
|
|
74
90
|
}
|
|
75
|
-
const centralBlock = text
|
|
91
|
+
const centralBlock = firstPrimaryQuarterMatch(text, /(?:^|[^\p{L}\p{N}_])(?:ц|c)\s*[-–]?\s*(\d{1,2})(?:$|[^\p{L}\p{N}_])/iu);
|
|
76
92
|
return centralBlock ? `C-${Number(centralBlock[1])}` : null;
|
|
77
93
|
}
|
|
78
94
|
|
package/src/housing-display.js
CHANGED
|
@@ -24,10 +24,23 @@ const DISPLAY = Object.freeze({
|
|
|
24
24
|
'Bobur Park': 'Парк Бобура', 'Independence Square': 'Площадь Независимости', 'Mega Planet': 'Mega Planet',
|
|
25
25
|
'Bek Baraka': 'Бек-Барака', Khadra: 'Хадра',
|
|
26
26
|
}),
|
|
27
|
+
uk: Object.freeze({
|
|
28
|
+
Park: 'Парк', Metro: 'Метро', 'Bus stop': 'Автобусна зупинка', 'Public transport': 'Громадський транспорт',
|
|
29
|
+
'Main road': 'Головна дорога', Clinic: 'Клініка', 'Maternity hospital': 'Пологовий будинок', Hospital: 'Лікарня',
|
|
30
|
+
School: 'Школа', Kindergarten: 'Дитячий садок', Childcare: 'Дитячі заклади', University: 'Університет',
|
|
31
|
+
'Shopping center': 'Торговий центр', Shop: 'Магазин', Korzinka: 'Korzinka', Supermarket: 'Супермаркет',
|
|
32
|
+
Market: 'Ринок', Cafe: 'Кафе', Restaurant: 'Ресторан', Playground: 'Дитячий майданчик', Pharmacy: 'Аптека',
|
|
33
|
+
Mosque: 'Мечеть', Church: 'Церква', 'Railway station': 'Залізничний вокзал', Airport: 'Аеропорт',
|
|
34
|
+
'Bobur Park': 'Парк Бабура', 'Independence Square': 'Площа Незалежності', 'Mega Planet': 'Mega Planet',
|
|
35
|
+
'Bek Baraka': 'Бек-Барака', Khadra: 'Хадра',
|
|
36
|
+
}),
|
|
27
37
|
});
|
|
28
38
|
|
|
29
39
|
function languageKey(locale) {
|
|
30
|
-
|
|
40
|
+
const key = String(locale || 'en').toLowerCase();
|
|
41
|
+
if (key.startsWith('uk')) return 'uk';
|
|
42
|
+
if (key.startsWith('ru')) return 'ru';
|
|
43
|
+
return 'en';
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
function exactPoi(value) {
|
|
@@ -3,6 +3,7 @@ import { findAllCanonical, normalizeUnicode } from './normalization.js';
|
|
|
3
3
|
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
4
4
|
import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
|
|
5
5
|
import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
|
|
6
|
+
import { TASHKENT_RESIDENTIAL_COMPLEXES, matchTashkentResidentialComplex } from './tashkent-residential-complexes.js';
|
|
6
7
|
import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
|
|
7
8
|
import { parseHousingListingFields } from './housing-listing-fields.js';
|
|
8
9
|
import { parseHousingResidentialComplex } from './housing-text.js';
|
|
@@ -26,18 +27,21 @@ const APPLIANCE_PATTERNS = Object.freeze([
|
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
|
|
29
|
-
const LANDLORD_PRESENT_RE = /(?:xozaykali|hojaykali|xo['’]?jaykali|с\s+хозяйк(?:ой|ой\s+в\s+квартире)|хозяйк\p{L}*\s+(?:жив[её]т|прожива\p{L}*))/iu;
|
|
30
|
-
const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s
|
|
31
|
-
const NO_BROKER_RE = /(?:bez\s+makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси))/iu;
|
|
32
|
-
const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission)/iu;
|
|
33
|
-
const MEN_RE = /(?:o['’ʻʼ‘`]?g['’ʻʼ‘`]?il\s+bola(?:lar)?(?:ga)?|ogil\s+bola(?:lar)?(?:ga)?|sherik\s+bola|эркак(?:лар)?|erkak(?:lar)?(?:ga)?|только\s+(?:мужчин|парн))/iu;
|
|
34
|
-
const WOMEN_RE = /(?:qiz(?:lar)?(?:ga)?|ayol(?:lar)?(?:ga|ni)?|киз(?:лар)?(?:га)?|аёл(?:лар)?(?:га|ни)?|девушк\p{L}*|женщин\p{L}*|girls?\s+only|women\s+only)/iu;
|
|
35
|
-
const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga)/iu;
|
|
36
|
-
const ROOM_SHARE_RE = /(?:sherik(?:ka|lik|likga)?|шерик(?:ка|лик)?|roommate|flatmate|подселени|койко[-\s]?мест|место\s+в\s+(?:комнат|квартир)|birga\s+yashash(?:ga)?|kvartira(?:ga|da)?[^\r\n.!?]{0,36}(?:\d+|bitta|1)\s*(?:ta\s*)?(?:qiz|ayol)[^\r\n.!?]{0,20}(?:ijarachi\s*)?(?:kerak|kere))/iu;
|
|
30
|
+
const LANDLORD_PRESENT_RE = /(?:xozaykali|hojaykali|xo['’]?jaykali|с\s+хозяйк(?:ой|ой\s+в\s+квартире)|хозяйк\p{L}*\s+(?:жив[её]т|прожива\p{L}*)|with\s+(?:the\s+)?(?:landlord|owner)\s+(?:present|living\s+in)|cu\s+proprietar(?:ul)?\s+în\s+cas(?:ă|a)|үй\s*иесі\s+(?:тұрады|бірге\s+тұрады))/iu;
|
|
31
|
+
const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s+студент|students?\s+(?:only|welcome)|for\s+students|pentru\s+studen[țt]i|studen[țt]i(?:lor)?|студенттерге|студенттер\s+үшін)/iu;
|
|
32
|
+
const NO_BROKER_RE = /(?:bez\s+makler|maklersiz|vositachisiz|без\s+(?:маклер|посредник|риелтор|риэлтор|комисси)|no\s+(?:broker|agent|commission|agency\s+fee)|f[ăa]r[ăa]\s+(?:comision|agen[țt]ie|intermediari)|делдалсыз|комиссиясыз)/iu;
|
|
33
|
+
const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
|
|
34
|
+
const MEN_RE = /(?:o['’ʻʼ‘`]?g['’ʻʼ‘`]?il\s+bola(?:lar)?(?:ga)?|ogil\s+bola(?:lar)?(?:ga)?|sherik\s+bola|эркак(?:лар)?|erkak(?:lar)?(?:ga)?|только\s+(?:мужчин|парн)|\bmen\s+only\b|b[ăa]rba[țt]i(?:lor)?|b[ăa]ie[țt]i(?:lor)?|жігіттерге|жігіттер(?:ге)?|хлопц(?:ям|і|ів)?|чоловік(?:ам|и)?)/iu;
|
|
35
|
+
const WOMEN_RE = /(?:qiz(?:lar)?(?:ga)?|ayol(?:lar)?(?:ga|ni)?|киз(?:лар)?(?:га)?|аёл(?:лар)?(?:га|ни)?|девушк\p{L}*|женщин\p{L}*|girls?\s+only|women\s+only|fete(?:lor)?|femei(?:lor)?|қыздарға|қыздар(?:ға)?|дівчат(?:ам|а|ок)?|жінк(?:ам|и)?)/iu;
|
|
36
|
+
const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga|famil(?:ie|ia)|cuplu(?:ri)?|отбасына|отбасылы|жанұяға|сім['’ʼ]?[яїі](?:ям|ям[иі])?|сімейн\p{L}*)/iu;
|
|
37
|
+
const ROOM_SHARE_RE = /(?:sherik(?:ka|lik|likga)?|шерик(?:ка|лик)?|roommate|flatmate|подселени|койко[-\s]?мест|место\s+в\s+(?:комнат|квартир)|birga\s+yashash(?:ga)?|kvartira(?:ga|da)?[^\r\n.!?]{0,36}(?:\d+|bitta|1)\s*(?:ta\s*)?(?:qiz|ayol)[^\r\n.!?]{0,20}(?:ijarachi\s*)?(?:kerak|kere)|coleg\s+de\s+(?:apartament|camer[ăa])|bed\s+space|бөлмелес(?:\s+керек)?|көрші\s+керек)/iu;
|
|
37
38
|
const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
|
|
38
39
|
const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
|
|
39
40
|
const WALK_MINUTES_RE = /(?:yayov|piyoda|пешком)\s*(\d{1,2})\s*(?:daqiqa|min(?:ute)?s?|минут)/iu;
|
|
40
41
|
const TRANSIT_ROUTES_RE = /(?:aftobuslar|avtobuslar|автобуслар|автобусы)[^\r\n\d]{0,24}((?:\d{1,4}[\s,;/]*){1,10})/iu;
|
|
42
|
+
const NEARBY_RELATION_TAIL_RE = /(?<!\p{L})(?:рядом\s+(?:с|со)|недалеко\s+от|возле|около|ориентир\s*[:—–-]?|near(?:by)?|close\s+to|lângă|aproape\s+de)(?!\p{L})[^.!?\r\n;]*/giu;
|
|
43
|
+
const NEARBY_TRAVEL_TAIL_RE = /(?<!\p{L})(?:до|până\s+la)(?!\p{L})[^.!?\r\n;]{0,96}(?<!\p{L})\d{1,3}\s*(?:мин(?:ут(?:ы|а|ах)?|\.?)?|min(?:ute)?s?|дақиқ\p{L}*|daqiqa|км|km|метр(?:а|ов)?|m)(?!\p{L})[^.!?\r\n;]*/giu;
|
|
44
|
+
const RESIDENTIAL_CONTEXT_RE = /(?:ж\.?\s*к\.?|жил(?:ой|ого)\s+комплекс|новострой(?:ка|ки)?|residential\s+complex|residence|turar\s+joy|uy[-\s]?joy|majmua|массив)/iu;
|
|
41
45
|
|
|
42
46
|
function unique(values) {
|
|
43
47
|
return [...new Set(values.filter(Boolean))];
|
|
@@ -47,6 +51,42 @@ function categoryOf(entry) {
|
|
|
47
51
|
return entry?.category || GENERIC_CATEGORY[entry?.canonical] || 'landmark';
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
function nearbyReferenceRanges(text) {
|
|
55
|
+
const ranges = [];
|
|
56
|
+
for (const pattern of [NEARBY_RELATION_TAIL_RE, NEARBY_TRAVEL_TAIL_RE]) {
|
|
57
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
58
|
+
for (const match of text.matchAll(regex)) {
|
|
59
|
+
const start = match.index ?? 0;
|
|
60
|
+
ranges.push({ start, end: start + match[0].length });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ranges;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function insideNearbyReference(match, ranges) {
|
|
67
|
+
return ranges.some((range) => match.start >= range.start && match.end <= range.end);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withoutNearbyLocationReferences(text) {
|
|
71
|
+
return String(text || '')
|
|
72
|
+
.replace(NEARBY_RELATION_TAIL_RE, ' ')
|
|
73
|
+
.replace(NEARBY_TRAVEL_TAIL_RE, ' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasResidentialContext(text, match) {
|
|
77
|
+
if (!match.entry?.ambiguous) return true;
|
|
78
|
+
const context = text.slice(Math.max(0, match.start - 36), Math.min(text.length, match.end + 36));
|
|
79
|
+
return RESIDENTIAL_CONTEXT_RE.test(context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function residentialComplexNearbyMatches(text) {
|
|
83
|
+
const ranges = nearbyReferenceRanges(text);
|
|
84
|
+
if (!ranges.length) return [];
|
|
85
|
+
return findAllCanonical(text, TASHKENT_RESIDENTIAL_COMPLEXES)
|
|
86
|
+
.filter((match) => insideNearbyReference(match, ranges) && hasResidentialContext(text, match))
|
|
87
|
+
.map((match) => ({ canonical: match.canonical, category: 'residential_complex', start: match.start }));
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
function extensionMatches(text) {
|
|
51
91
|
return findAllCanonical(text, HOUSING_POI_EXTENSIONS).map((match) => ({
|
|
52
92
|
canonical: match.canonical,
|
|
@@ -83,7 +123,7 @@ function genericMatches(text) {
|
|
|
83
123
|
export function parseHousingNearby(value) {
|
|
84
124
|
const text = normalizeUnicode(value ?? '');
|
|
85
125
|
if (!text) return deepFreeze([]);
|
|
86
|
-
const specific = [...extensionMatches(text), ...catalogPoiMatches(text)].sort((a, b) => a.start - b.start);
|
|
126
|
+
const specific = [...extensionMatches(text), ...catalogPoiMatches(text), ...residentialComplexNearbyMatches(text)].sort((a, b) => a.start - b.start);
|
|
87
127
|
const specificCategories = new Set(specific.map((item) => item.category));
|
|
88
128
|
const generic = genericMatches(text)
|
|
89
129
|
.filter((item) => !specificCategories.has(item.category))
|
|
@@ -201,7 +241,10 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
201
241
|
const quarter = matchTashkentHousingQuarter(text);
|
|
202
242
|
const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
|
|
203
243
|
const metro = matchTashkentHousingMetro(text)?.name || null;
|
|
204
|
-
const
|
|
244
|
+
const primaryResidentialText = withoutNearbyLocationReferences(text);
|
|
245
|
+
const parsedRc = specificResidentialComplex(primaryResidentialText)
|
|
246
|
+
|| matchTashkentResidentialComplex(primaryResidentialText)?.name
|
|
247
|
+
|| parseHousingResidentialComplex(primaryResidentialText);
|
|
205
248
|
const commission = parseHousingCommission(text);
|
|
206
249
|
const commissionAmount = parseHousingCommissionAmount(text);
|
|
207
250
|
const address = parseHousingAddress(text);
|
package/src/housing-money.js
CHANGED
|
@@ -271,4 +271,4 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
// Compatibility name for callers migrating from Flat Finder's local parser.
|
|
274
|
-
export const parsePriceFromText = parseHousingPrice;
|
|
274
|
+
export const parsePriceFromText = parseHousingPrice;
|
|
@@ -108,6 +108,12 @@ export const HOUSING_POI_EXTENSIONS = Object.freeze([
|
|
|
108
108
|
uzLatn: ['Assalom Jomiy', 'Assalom Jomiy turar joy majmuasi'],
|
|
109
109
|
uzCyrl: ['Ассалом Жомий'],
|
|
110
110
|
}, { category: 'residential_complex', display: { ru: 'ЖК «Ассалом Жомий»', en: 'Assalom Jomiy' } }),
|
|
111
|
+
group('Assalom Sohil', {
|
|
112
|
+
ru: ['Ассалом Сохил', 'ЖК Ассалом Сохил', 'жилой комплекс Ассалом Сохил'],
|
|
113
|
+
en: ['Assalom Sohil', 'Assalom Sohil residential complex'],
|
|
114
|
+
uzLatn: ['Assalom Sohil', 'Assalom Sohil turar joy majmuasi'],
|
|
115
|
+
uzCyrl: ['Ассалом Сохил'],
|
|
116
|
+
}, { category: 'residential_complex', display: { ru: 'ЖК «Ассалом Сохил»', en: 'Assalom Sohil' } }),
|
|
111
117
|
group('Jomiy Roundabout', {
|
|
112
118
|
ru: ['круг Жомий', 'круг жомий', 'кольцо Жомий'],
|
|
113
119
|
en: ['Jomiy Roundabout', 'Jomiy circle'],
|
|
@@ -10,6 +10,7 @@ export const HOUSING_DEAL_TYPE_EXTENSIONS = Object.freeze([
|
|
|
10
10
|
group('longRent', {
|
|
11
11
|
ru: ['снять', 'в месяц', 'месяц'],
|
|
12
12
|
en: ['monthly'],
|
|
13
|
+
uk: ['зняти', 'на місяць', 'місяць'],
|
|
13
14
|
uzLatn: ['ijaraga', 'oyiga', 'beriladi'],
|
|
14
15
|
uzCyrl: ['ижарага', 'ойига', 'берилади'],
|
|
15
16
|
kk: ['айына'],
|
|
@@ -17,6 +18,7 @@ export const HOUSING_DEAL_TYPE_EXTENSIONS = Object.freeze([
|
|
|
17
18
|
group('shortRent', {
|
|
18
19
|
ru: ['сутки', 'суток', 'суточно'],
|
|
19
20
|
en: ['daily'],
|
|
21
|
+
uk: ['доба', 'діб', 'подобово'],
|
|
20
22
|
uzLatn: ['kunlik', 'sutkalik'],
|
|
21
23
|
uzCyrl: ['кунлик', 'суткалик'],
|
|
22
24
|
kk: ['тәуліктік'],
|
|
@@ -27,6 +29,7 @@ export const HOUSING_ROOM_ONLY_EXTENSIONS = Object.freeze([
|
|
|
27
29
|
group('roomOnly', {
|
|
28
30
|
ru: ['шеринг'],
|
|
29
31
|
en: ['room only'],
|
|
32
|
+
uk: ['шерінг'],
|
|
30
33
|
uzLatn: ['student qizlarga', 'talaba qizlarga', 'opshijit dom', 'obshijit dom'],
|
|
31
34
|
uzCyrl: ['студент қизларга', 'талаба қизларга'],
|
|
32
35
|
}),
|
|
@@ -84,6 +84,23 @@ export function parseHousingFloor(value) {
|
|
|
84
84
|
const text = normalizeUnicode(value ?? '');
|
|
85
85
|
if (!text) return deepFreeze({ floor: null, totalFloors: null });
|
|
86
86
|
|
|
87
|
+
// Common classifieds shorthand is rooms/floor/total floors, e.g. 2/10/16.
|
|
88
|
+
// It must be checked before the generic floor/total fraction parser or the
|
|
89
|
+
// first two numbers would incorrectly become floor=2, totalFloors=10.
|
|
90
|
+
// Requiring the triple to occupy its own line avoids treating dates as this
|
|
91
|
+
// housing-specific shorthand inside ordinary prose.
|
|
92
|
+
const compactTriple = text.match(/(?:^|[\r\n])\s*(\d{1,2})\s*[\/\\]\s*(\d{1,3})\s*[\/\\]\s*(\d{1,3})\s*(?=$|[\r\n])/u);
|
|
93
|
+
if (compactTriple) {
|
|
94
|
+
const rooms = toNumber(compactTriple[1]);
|
|
95
|
+
const floor = toNumber(compactTriple[2]);
|
|
96
|
+
const totalFloors = toNumber(compactTriple[3]);
|
|
97
|
+
if (rooms != null && rooms >= 1 && rooms <= 20
|
|
98
|
+
&& floor != null && totalFloors != null
|
|
99
|
+
&& floor >= 1 && floor <= totalFloors && totalFloors <= 200) {
|
|
100
|
+
return deepFreeze({ floor, totalFloors });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*[\/\\]\s*(\d{1,3})(?=$|[^\d])/u);
|
|
88
105
|
if (fraction) {
|
|
89
106
|
const floor = toNumber(fraction[1]);
|
|
@@ -142,7 +159,7 @@ const AREA_LABELS = Object.freeze([
|
|
|
142
159
|
['total', /(?:общая\s+площадь|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
|
|
143
160
|
]);
|
|
144
161
|
|
|
145
|
-
const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s
|
|
162
|
+
const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s*м|кв2|квадрат\p{L}*)`;
|
|
146
163
|
|
|
147
164
|
function areaAfterLabel(text, labelRe) {
|
|
148
165
|
const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_RE}`, 'iu');
|