@whiteslove/parsing-lexicon 0.2.0 → 0.2.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/LICENSE +21 -21
- package/README.md +137 -137
- package/index.d.ts +374 -374
- package/package.json +77 -75
- package/src/central-asia-locations.js +211 -211
- package/src/central-asia.js +233 -233
- package/src/contact.d.ts +44 -44
- package/src/contact.js +159 -159
- package/src/countries.js +175 -175
- package/src/country-context.d.ts +10 -10
- package/src/country-context.js +22 -22
- package/src/currency.d.ts +14 -14
- package/src/currency.js +66 -66
- package/src/geo.js +169 -169
- package/src/geography-central-asia.js +42 -42
- package/src/geography-detection.d.ts +14 -14
- package/src/geography-detection.js +64 -64
- package/src/geography-display.d.ts +13 -13
- package/src/geography-display.js +113 -113
- package/src/geography.js +210 -210
- package/src/hiring-advanced.js +147 -147
- package/src/hiring-candidate-fields.d.ts +9 -9
- package/src/hiring-candidate-fields.js +92 -92
- package/src/hiring-context.d.ts +64 -64
- package/src/hiring-context.js +412 -412
- package/src/hiring-language-extensions.d.ts +19 -19
- package/src/hiring-language-extensions.js +139 -139
- package/src/hiring-languages.js +135 -135
- package/src/hiring-location-fields.d.ts +3 -3
- package/src/hiring-location-fields.js +53 -53
- package/src/hiring-professions-ro.js +191 -191
- package/src/hiring-professions.js +437 -437
- package/src/hiring-requirements.d.ts +16 -16
- package/src/hiring-requirements.js +170 -170
- package/src/hiring-salary-context.d.ts +27 -27
- package/src/hiring-salary-context.js +76 -76
- package/src/hiring-semantics.d.ts +29 -29
- package/src/hiring-semantics.js +218 -218
- package/src/hiring-skills.d.ts +21 -21
- package/src/hiring-skills.js +315 -315
- package/src/hiring-source-aliases.d.ts +32 -32
- package/src/hiring-source-aliases.js +185 -185
- package/src/hiring-source-semantics.d.ts +31 -31
- package/src/hiring-source-semantics.js +293 -293
- package/src/hiring-temporal.d.ts +15 -15
- package/src/hiring-temporal.js +67 -67
- package/src/hiring-vacancy-fields.d.ts +5 -5
- package/src/hiring-vacancy-fields.js +23 -23
- package/src/hiring-work-semantics.d.ts +34 -34
- package/src/hiring-work-semantics.js +72 -72
- package/src/hiring.js +180 -180
- package/src/housing-address.d.ts +18 -18
- package/src/housing-address.js +217 -210
- package/src/housing-commercial.d.ts +1 -0
- package/src/housing-commercial.js +9 -0
- package/src/housing-context.d.ts +36 -36
- package/src/housing-context.js +183 -183
- package/src/housing-features.js +30 -30
- package/src/housing-intent.d.ts +9 -9
- package/src/housing-intent.js +132 -132
- package/src/housing-listing-fields.js +131 -131
- package/src/housing-money.d.ts +7 -7
- package/src/housing-money.js +102 -102
- package/src/housing-source-aliases.d.ts +8 -8
- package/src/housing-source-aliases.js +51 -51
- package/src/housing-structured.d.ts +55 -55
- package/src/housing-structured.js +219 -219
- package/src/housing.js +200 -200
- package/src/index.js +45 -44
- package/src/kz-location-extensions.js +307 -307
- package/src/landmarks.js +21 -21
- package/src/lexicon-core.js +58 -58
- package/src/location-merge.js +106 -106
- package/src/locations.js +456 -456
- package/src/money-core.d.ts +10 -10
- package/src/money-core.js +81 -81
- package/src/money-lexicon.d.ts +5 -5
- package/src/money-lexicon.js +79 -79
- package/src/money.js +154 -154
- package/src/normalization.js +384 -384
- package/src/odesa-metropolitan.js +144 -144
- package/src/romania-geography.js +47 -47
- package/src/tashkent-colloquial.js +80 -72
- package/src/tashkent-housing-geography.d.ts +38 -38
- package/src/tashkent-housing-geography.js +211 -211
- package/src/tashkent-pois.js +215 -215
- package/src/tashkent-residential-complexes.js +272 -272
- package/src/ua-location-extensions-major.js +118 -118
- package/src/ua-location-extensions-metro.js +9 -9
- package/src/ua-location-extensions-regional.js +246 -246
- package/src/ua-secondary-cities.d.ts +2 -2
- package/src/ua-secondary-cities.js +57 -57
- package/src/ukraine.js +75 -75
- package/src/uz-location-extensions.js +259 -259
|
@@ -1,219 +1,219 @@
|
|
|
1
|
-
import { deepFreeze } from './lexicon-core.js';
|
|
2
|
-
import { findAllCanonical, findCanonical, normalizeUnicode } from './normalization.js';
|
|
3
|
-
import { CURRENCY_TERMS } from './money.js';
|
|
4
|
-
import { DEPOSIT_TERMS, SELLER_TERMS, UTILITY_TERMS } from './housing.js';
|
|
5
|
-
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
6
|
-
import { LOCATION_RELATIONS, parseHousingContext } from './housing-context.js';
|
|
7
|
-
import { resolveHousingIntent } from './housing-intent.js';
|
|
8
|
-
|
|
9
|
-
const NUMBER_WORDS = Object.freeze([
|
|
10
|
-
[/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|1\s*[- ]?к(?:омн\p{L}*)?|1\s*xona(?:li)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
|
|
11
|
-
[/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|2\s*[- ]?к(?:омн\p{L}*)?|2\s*xona(?:li)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
|
|
12
|
-
[/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|3\s*[- ]?к(?:омн\p{L}*)?|3\s*xona(?:li)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
|
|
13
|
-
[/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|4\s*[- ]?к(?:омн\p{L}*)?|4\s*xona(?:li)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
|
-
function toNumber(value) {
|
|
17
|
-
if (value == null) return null;
|
|
18
|
-
const normalized = String(value).replace(/\s+/g, '').replace(',', '.');
|
|
19
|
-
const number = Number(normalized);
|
|
20
|
-
return Number.isFinite(number) ? number : null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function currencyNear(text) {
|
|
24
|
-
return findCanonical(text, CURRENCY_TERMS, { partial: true })?.canonical || null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function parseHousingRoomCount(value) {
|
|
28
|
-
const text = normalizeUnicode(value ?? '');
|
|
29
|
-
if (!text) return null;
|
|
30
|
-
for (const [re, rooms] of NUMBER_WORDS) if (re.test(text)) return rooms;
|
|
31
|
-
const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:[- ]?комнат\p{L}*|[- ]?к(?:\.|\b)|xona(?:li)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
|
|
32
|
-
const rooms = toNumber(numeric?.[1]);
|
|
33
|
-
return rooms != null && rooms >= 1 && rooms <= 20 ? rooms : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function parseHousingFloor(value) {
|
|
37
|
-
const text = normalizeUnicode(value ?? '');
|
|
38
|
-
if (!text) return deepFreeze({ floor: null, totalFloors: null });
|
|
39
|
-
|
|
40
|
-
const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*\/\s*(\d{1,3})(?=$|[^\d])/u);
|
|
41
|
-
if (fraction) {
|
|
42
|
-
const floor = toNumber(fraction[1]);
|
|
43
|
-
const totalFloors = toNumber(fraction[2]);
|
|
44
|
-
if (floor != null && totalFloors != null && floor <= totalFloors && totalFloors <= 200) {
|
|
45
|
-
return deepFreeze({ floor, totalFloors });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const explicit = text.match(/(?:этаж|поверх|floor|etaj|qavat|қабат)\s*[:№#-]?\s*(\d{1,3})\s*(?:[,;/]|из|of|din|dan)?\s*(?:дом\s*)?(?:из\s*)?(\d{1,3})?\s*(?:этаж\p{L}*|поверх\p{L}*|floors?|etaje|qavatli|қабатты)?/iu);
|
|
50
|
-
let floor = toNumber(explicit?.[1]);
|
|
51
|
-
let totalFloors = toNumber(explicit?.[2]);
|
|
52
|
-
|
|
53
|
-
if (totalFloors == null) {
|
|
54
|
-
const total = text.match(/(?:дом\s*)?(\d{1,3})\s*(?:[- ]?этажн\p{L}*|поверхов\p{L}*|storey|story|floors?\s+total|qavatli|қабатты)/iu);
|
|
55
|
-
totalFloors = toNumber(total?.[1]);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (floor != null && (floor < -5 || floor > 200)) floor = null;
|
|
59
|
-
if (totalFloors != null && (totalFloors < 1 || totalFloors > 200)) totalFloors = null;
|
|
60
|
-
if (floor != null && totalFloors != null && floor > totalFloors) totalFloors = null;
|
|
61
|
-
return deepFreeze({ floor, totalFloors });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const AREA_LABELS = Object.freeze([
|
|
65
|
-
['living', /(?:жилая\s+площадь|жилая|living\s+area|suprafa(?:ță|ta)\s+locuibil(?:ă|a)|yashash\s+maydoni|тұрғын\s+аудан)/iu],
|
|
66
|
-
['kitchen', /(?:площадь\s+кухни|кухня|kitchen(?:\s+area)?|bucătărie|bucatarie|oshxona|асүй)/iu],
|
|
67
|
-
['balcony', /(?:балкон|лоджия|balcony|loggia|balcon|balkon)/iu],
|
|
68
|
-
['terrace', /(?:терраса|terrace|teras(?:ă|a)|terrasa)/iu],
|
|
69
|
-
['total', /(?:общая\s+площадь|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
|
|
70
|
-
]);
|
|
71
|
-
|
|
72
|
-
function areaAfterLabel(text, labelRe) {
|
|
73
|
-
const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*(?:м²|м2|m²|m2|sqm|sq\\.?\\s*m|mp|кв\\.?\\s*м)`, 'iu');
|
|
74
|
-
return toNumber(text.match(re)?.[1]);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function parseHousingAreas(value) {
|
|
78
|
-
const text = normalizeUnicode(value ?? '');
|
|
79
|
-
const result = { total: null, living: null, kitchen: null, balcony: null, terrace: null };
|
|
80
|
-
if (!text) return deepFreeze(result);
|
|
81
|
-
|
|
82
|
-
for (const [key, re] of AREA_LABELS) result[key] = areaAfterLabel(text, re);
|
|
83
|
-
if (result.total == null) {
|
|
84
|
-
const generic = text.match(/(?:^|[^\d])(\d{1,4}(?:[.,]\d{1,2})?)\s*(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp)(?=$|[^\p{L}\p{N}])/iu);
|
|
85
|
-
result.total = toNumber(generic?.[1]);
|
|
86
|
-
}
|
|
87
|
-
for (const key of Object.keys(result)) {
|
|
88
|
-
const number = result[key];
|
|
89
|
-
if (number != null && (number <= 0 || number > 100000)) result[key] = null;
|
|
90
|
-
}
|
|
91
|
-
return deepFreeze(result);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function amountAroundKeyword(text, keywordRe) {
|
|
95
|
-
const after = text.match(new RegExp(`${keywordRe.source}[^\\d$€₴₸]{0,24}([$€₴₸])?\\s*(\\d{1,9}(?:[.,]\\d{1,2})?)\\s*([\\p{L}.']{0,12})`, 'iu'));
|
|
96
|
-
const before = text.match(new RegExp(`([$€₴₸])?\\s*(\\d{1,9}(?:[.,]\\d{1,2})?)\\s*([\\p{L}.']{0,12})[^\\d]{0,16}${keywordRe.source}`, 'iu'));
|
|
97
|
-
const match = after || before;
|
|
98
|
-
if (!match) return { amount: null, currency: null };
|
|
99
|
-
const amount = toNumber(match[2]);
|
|
100
|
-
const context = match[0];
|
|
101
|
-
return { amount, currency: currencyNear(context) };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function parseHousingPayments(value) {
|
|
105
|
-
const text = normalizeUnicode(value ?? '');
|
|
106
|
-
if (!text) return deepFreeze({
|
|
107
|
-
deposit: { required: null, kind: null, amount: null, currency: null },
|
|
108
|
-
prepaymentMonths: null,
|
|
109
|
-
utilities: null,
|
|
110
|
-
commission: { required: null, percent: null },
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const depositMatches = Object.values(DEPOSIT_TERMS)
|
|
114
|
-
.map((entry) => findCanonical(text, [entry], { partial: true })?.canonical)
|
|
115
|
-
.filter(Boolean);
|
|
116
|
-
const depositPriority = ['noDeposit', 'firstAndLastMonth', 'advance', 'refundableDeposit', 'deposit'];
|
|
117
|
-
const depositKind = depositPriority.find((item) => depositMatches.includes(item)) || null;
|
|
118
|
-
const depositAmount = amountAroundKeyword(text, /(?:депозит|залог|deposit|depozit|garanție|garantie|кепіл)/iu);
|
|
119
|
-
|
|
120
|
-
let prepaymentMonths = toNumber(text.match(/(?:предоплат\p{L}*|оплат\p{L}*\s+впер[её]д|prepay(?:ment)?|advance\s+payment|oldindan\s+to['’]?lov|алдын\s+ала\s+төлем)\D{0,18}(\d{1,2})\s*(?:месяц\p{L}*|months?|oy|ай)/iu)?.[1]);
|
|
121
|
-
if (depositKind === 'firstAndLastMonth' && prepaymentMonths == null) prepaymentMonths = 2;
|
|
122
|
-
|
|
123
|
-
let utilities = null;
|
|
124
|
-
for (const entry of Object.values(UTILITY_TERMS)) {
|
|
125
|
-
const matched = findCanonical(text, [entry], { partial: true });
|
|
126
|
-
if (matched) { utilities = matched.canonical; break; }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const noCommission = SELLER_TERMS.noCommission && findCanonical(text, [SELLER_TERMS.noCommission], { partial: true });
|
|
130
|
-
const commissionPercent = toNumber(text.match(/(?:комисси\p{L}*|commission|comision|komissiya)[^\d%]{0,16}(\d{1,3}(?:[.,]\d+)?)\s*%/iu)?.[1]);
|
|
131
|
-
const commissionMentioned = SELLER_TERMS.commission && findCanonical(text, [SELLER_TERMS.commission], { partial: true });
|
|
132
|
-
|
|
133
|
-
return deepFreeze({
|
|
134
|
-
deposit: {
|
|
135
|
-
required: depositKind === 'noDeposit' ? false : depositKind ? true : null,
|
|
136
|
-
kind: depositKind,
|
|
137
|
-
amount: depositAmount.amount,
|
|
138
|
-
currency: depositAmount.currency,
|
|
139
|
-
},
|
|
140
|
-
prepaymentMonths,
|
|
141
|
-
utilities,
|
|
142
|
-
commission: {
|
|
143
|
-
required: noCommission ? false : (commissionMentioned || commissionPercent != null ? true : null),
|
|
144
|
-
percent: commissionPercent,
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function parseHousingSeller(value) {
|
|
150
|
-
const text = normalizeUnicode(value ?? '');
|
|
151
|
-
if (!text) return deepFreeze({ type: null, confidence: 0 });
|
|
152
|
-
const owner = SELLER_TERMS.owner && findCanonical(text, [SELLER_TERMS.owner], { partial: true });
|
|
153
|
-
const agency = SELLER_TERMS.agency && findCanonical(text, [SELLER_TERMS.agency], { partial: true });
|
|
154
|
-
if (owner && !agency) return deepFreeze({ type: 'owner', confidence: 1 });
|
|
155
|
-
if (agency && !owner) return deepFreeze({ type: 'agency', confidence: 1 });
|
|
156
|
-
if (owner && agency) return deepFreeze({ type: null, confidence: 0.45 });
|
|
157
|
-
return deepFreeze({ type: null, confidence: 0 });
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function distanceFromWindow(window, entityOffset = 0) {
|
|
161
|
-
const candidates = [];
|
|
162
|
-
const minuteRe = /(\d{1,3})\s*(?:мин(?:ут[аы]?)?|minutes?|min\.?|daqiqa|минут|мин|минөт)/giu;
|
|
163
|
-
for (const match of window.matchAll(minuteRe)) {
|
|
164
|
-
const index = match.index ?? 0;
|
|
165
|
-
const local = window.slice(Math.max(0, index - 18), Math.min(window.length, index + match[0].length + 28));
|
|
166
|
-
const mode = /(?:пешком|walk(?:ing)?|on\s+foot|piyoda|жаяу)/iu.test(local)
|
|
167
|
-
? 'walk'
|
|
168
|
-
: /(?:на\s+машине|by\s+car|drive|mashinada|көлікпен)/iu.test(local) ? 'drive' : null;
|
|
169
|
-
candidates.push({ distance: Math.abs(index - entityOffset), value: Number(match[1]), unit: 'minute', mode });
|
|
170
|
-
}
|
|
171
|
-
const metricRe = /(\d{1,4}(?:[.,]\d+)?)\s*(км|km|километр\p{L}*|м|meter(?:s)?|метр\p{L}*)/giu;
|
|
172
|
-
for (const match of window.matchAll(metricRe)) {
|
|
173
|
-
const index = match.index ?? 0;
|
|
174
|
-
const rawUnit = match[2].toLocaleLowerCase();
|
|
175
|
-
candidates.push({ distance: Math.abs(index - entityOffset), value: toNumber(match[1]), unit: /км|km|километр/u.test(rawUnit) ? 'kilometer' : 'meter', mode: null });
|
|
176
|
-
}
|
|
177
|
-
candidates.sort((a, b) => a.distance - b.distance);
|
|
178
|
-
if (!candidates.length) return null;
|
|
179
|
-
const { value, unit, mode } = candidates[0];
|
|
180
|
-
return { value, unit, mode };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function parseHousingInfrastructure(value) {
|
|
184
|
-
const text = normalizeUnicode(value ?? '');
|
|
185
|
-
if (!text) return [];
|
|
186
|
-
const matches = findAllCanonical(text, GENERIC_LANDMARK_TERMS);
|
|
187
|
-
const out = [];
|
|
188
|
-
const seen = new Set();
|
|
189
|
-
for (const match of matches) {
|
|
190
|
-
const key = `${match.canonical}:${match.start}`;
|
|
191
|
-
if (seen.has(key)) continue;
|
|
192
|
-
seen.add(key);
|
|
193
|
-
const left = Math.max(0, match.start - 80);
|
|
194
|
-
const right = Math.min(text.length, match.end + 80);
|
|
195
|
-
const window = text.slice(left, right);
|
|
196
|
-
out.push(deepFreeze({
|
|
197
|
-
poi: match.canonical,
|
|
198
|
-
relation: findCanonical(window, LOCATION_RELATIONS, { partial: true })?.canonical || null,
|
|
199
|
-
distance: distanceFromWindow(window, match.start - left),
|
|
200
|
-
start: match.start,
|
|
201
|
-
end: match.end,
|
|
202
|
-
}));
|
|
203
|
-
}
|
|
204
|
-
return Object.freeze(out);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function parseHousingStructured(value) {
|
|
208
|
-
const text = String(value ?? '');
|
|
209
|
-
return deepFreeze({
|
|
210
|
-
intent: resolveHousingIntent(text),
|
|
211
|
-
context: parseHousingContext(text),
|
|
212
|
-
rooms: parseHousingRoomCount(text),
|
|
213
|
-
floor: parseHousingFloor(text),
|
|
214
|
-
area: parseHousingAreas(text),
|
|
215
|
-
payments: parseHousingPayments(text),
|
|
216
|
-
seller: parseHousingSeller(text),
|
|
217
|
-
infrastructure: parseHousingInfrastructure(text),
|
|
218
|
-
});
|
|
219
|
-
}
|
|
1
|
+
import { deepFreeze } from './lexicon-core.js';
|
|
2
|
+
import { findAllCanonical, findCanonical, normalizeUnicode } from './normalization.js';
|
|
3
|
+
import { CURRENCY_TERMS } from './money.js';
|
|
4
|
+
import { DEPOSIT_TERMS, SELLER_TERMS, UTILITY_TERMS } from './housing.js';
|
|
5
|
+
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
6
|
+
import { LOCATION_RELATIONS, parseHousingContext } from './housing-context.js';
|
|
7
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
8
|
+
|
|
9
|
+
const NUMBER_WORDS = Object.freeze([
|
|
10
|
+
[/(?<![\p{L}\p{N}_])(?:однушк\p{L}*|однокомнатн\p{L}*|1\s*[- ]?к(?:омн\p{L}*)?|1\s*xona(?:li)?|1\s*бөлмелі|one[- ]bedroom|one[- ]room)(?![\p{L}\p{N}_])/iu, 1],
|
|
11
|
+
[/(?<![\p{L}\p{N}_])(?:двушк\p{L}*|двухкомнатн\p{L}*|2\s*[- ]?к(?:омн\p{L}*)?|2\s*xona(?:li)?|2\s*бөлмелі|two[- ]bedroom|two[- ]room)(?![\p{L}\p{N}_])/iu, 2],
|
|
12
|
+
[/(?<![\p{L}\p{N}_])(?:тр[её]шк\p{L}*|трехкомнатн\p{L}*|трёхкомнатн\p{L}*|3\s*[- ]?к(?:омн\p{L}*)?|3\s*xona(?:li)?|3\s*бөлмелі|three[- ]bedroom|three[- ]room)(?![\p{L}\p{N}_])/iu, 3],
|
|
13
|
+
[/(?<![\p{L}\p{N}_])(?:четыр[её]хкомнатн\p{L}*|четыр[её]шк\p{L}*|4\s*[- ]?к(?:омн\p{L}*)?|4\s*xona(?:li)?|4\s*бөлмелі|four[- ]bedroom|four[- ]room)(?![\p{L}\p{N}_])/iu, 4],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function toNumber(value) {
|
|
17
|
+
if (value == null) return null;
|
|
18
|
+
const normalized = String(value).replace(/\s+/g, '').replace(',', '.');
|
|
19
|
+
const number = Number(normalized);
|
|
20
|
+
return Number.isFinite(number) ? number : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function currencyNear(text) {
|
|
24
|
+
return findCanonical(text, CURRENCY_TERMS, { partial: true })?.canonical || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseHousingRoomCount(value) {
|
|
28
|
+
const text = normalizeUnicode(value ?? '');
|
|
29
|
+
if (!text) return null;
|
|
30
|
+
for (const [re, rooms] of NUMBER_WORDS) if (re.test(text)) return rooms;
|
|
31
|
+
const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:[- ]?комнат\p{L}*|[- ]?к(?:\.|\b)|xona(?:li)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
|
|
32
|
+
const rooms = toNumber(numeric?.[1]);
|
|
33
|
+
return rooms != null && rooms >= 1 && rooms <= 20 ? rooms : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseHousingFloor(value) {
|
|
37
|
+
const text = normalizeUnicode(value ?? '');
|
|
38
|
+
if (!text) return deepFreeze({ floor: null, totalFloors: null });
|
|
39
|
+
|
|
40
|
+
const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*\/\s*(\d{1,3})(?=$|[^\d])/u);
|
|
41
|
+
if (fraction) {
|
|
42
|
+
const floor = toNumber(fraction[1]);
|
|
43
|
+
const totalFloors = toNumber(fraction[2]);
|
|
44
|
+
if (floor != null && totalFloors != null && floor <= totalFloors && totalFloors <= 200) {
|
|
45
|
+
return deepFreeze({ floor, totalFloors });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const explicit = text.match(/(?:этаж|поверх|floor|etaj|qavat|қабат)\s*[:№#-]?\s*(\d{1,3})\s*(?:[,;/]|из|of|din|dan)?\s*(?:дом\s*)?(?:из\s*)?(\d{1,3})?\s*(?:этаж\p{L}*|поверх\p{L}*|floors?|etaje|qavatli|қабатты)?/iu);
|
|
50
|
+
let floor = toNumber(explicit?.[1]);
|
|
51
|
+
let totalFloors = toNumber(explicit?.[2]);
|
|
52
|
+
|
|
53
|
+
if (totalFloors == null) {
|
|
54
|
+
const total = text.match(/(?:дом\s*)?(\d{1,3})\s*(?:[- ]?этажн\p{L}*|поверхов\p{L}*|storey|story|floors?\s+total|qavatli|қабатты)/iu);
|
|
55
|
+
totalFloors = toNumber(total?.[1]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (floor != null && (floor < -5 || floor > 200)) floor = null;
|
|
59
|
+
if (totalFloors != null && (totalFloors < 1 || totalFloors > 200)) totalFloors = null;
|
|
60
|
+
if (floor != null && totalFloors != null && floor > totalFloors) totalFloors = null;
|
|
61
|
+
return deepFreeze({ floor, totalFloors });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const AREA_LABELS = Object.freeze([
|
|
65
|
+
['living', /(?:жилая\s+площадь|жилая|living\s+area|suprafa(?:ță|ta)\s+locuibil(?:ă|a)|yashash\s+maydoni|тұрғын\s+аудан)/iu],
|
|
66
|
+
['kitchen', /(?:площадь\s+кухни|кухня|kitchen(?:\s+area)?|bucătărie|bucatarie|oshxona|асүй)/iu],
|
|
67
|
+
['balcony', /(?:балкон|лоджия|balcony|loggia|balcon|balkon)/iu],
|
|
68
|
+
['terrace', /(?:терраса|terrace|teras(?:ă|a)|terrasa)/iu],
|
|
69
|
+
['total', /(?:общая\s+площадь|площадь|total\s+area|surface\s+area|suprafa(?:ță|ta)(?:\s+total(?:ă|a))?|umumiy\s+maydon|жалпы\s+аудан)/iu],
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function areaAfterLabel(text, labelRe) {
|
|
73
|
+
const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*(?:м²|м2|m²|m2|sqm|sq\\.?\\s*m|mp|кв\\.?\\s*м)`, 'iu');
|
|
74
|
+
return toNumber(text.match(re)?.[1]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseHousingAreas(value) {
|
|
78
|
+
const text = normalizeUnicode(value ?? '');
|
|
79
|
+
const result = { total: null, living: null, kitchen: null, balcony: null, terrace: null };
|
|
80
|
+
if (!text) return deepFreeze(result);
|
|
81
|
+
|
|
82
|
+
for (const [key, re] of AREA_LABELS) result[key] = areaAfterLabel(text, re);
|
|
83
|
+
if (result.total == null) {
|
|
84
|
+
const generic = text.match(/(?:^|[^\d])(\d{1,4}(?:[.,]\d{1,2})?)\s*(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp)(?=$|[^\p{L}\p{N}])/iu);
|
|
85
|
+
result.total = toNumber(generic?.[1]);
|
|
86
|
+
}
|
|
87
|
+
for (const key of Object.keys(result)) {
|
|
88
|
+
const number = result[key];
|
|
89
|
+
if (number != null && (number <= 0 || number > 100000)) result[key] = null;
|
|
90
|
+
}
|
|
91
|
+
return deepFreeze(result);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function amountAroundKeyword(text, keywordRe) {
|
|
95
|
+
const after = text.match(new RegExp(`${keywordRe.source}[^\\d$€₴₸]{0,24}([$€₴₸])?\\s*(\\d{1,9}(?:[.,]\\d{1,2})?)\\s*([\\p{L}.']{0,12})`, 'iu'));
|
|
96
|
+
const before = text.match(new RegExp(`([$€₴₸])?\\s*(\\d{1,9}(?:[.,]\\d{1,2})?)\\s*([\\p{L}.']{0,12})[^\\d]{0,16}${keywordRe.source}`, 'iu'));
|
|
97
|
+
const match = after || before;
|
|
98
|
+
if (!match) return { amount: null, currency: null };
|
|
99
|
+
const amount = toNumber(match[2]);
|
|
100
|
+
const context = match[0];
|
|
101
|
+
return { amount, currency: currencyNear(context) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function parseHousingPayments(value) {
|
|
105
|
+
const text = normalizeUnicode(value ?? '');
|
|
106
|
+
if (!text) return deepFreeze({
|
|
107
|
+
deposit: { required: null, kind: null, amount: null, currency: null },
|
|
108
|
+
prepaymentMonths: null,
|
|
109
|
+
utilities: null,
|
|
110
|
+
commission: { required: null, percent: null },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const depositMatches = Object.values(DEPOSIT_TERMS)
|
|
114
|
+
.map((entry) => findCanonical(text, [entry], { partial: true })?.canonical)
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
const depositPriority = ['noDeposit', 'firstAndLastMonth', 'advance', 'refundableDeposit', 'deposit'];
|
|
117
|
+
const depositKind = depositPriority.find((item) => depositMatches.includes(item)) || null;
|
|
118
|
+
const depositAmount = amountAroundKeyword(text, /(?:депозит|залог|deposit|depozit|garanție|garantie|кепіл)/iu);
|
|
119
|
+
|
|
120
|
+
let prepaymentMonths = toNumber(text.match(/(?:предоплат\p{L}*|оплат\p{L}*\s+впер[её]д|prepay(?:ment)?|advance\s+payment|oldindan\s+to['’]?lov|алдын\s+ала\s+төлем)\D{0,18}(\d{1,2})\s*(?:месяц\p{L}*|months?|oy|ай)/iu)?.[1]);
|
|
121
|
+
if (depositKind === 'firstAndLastMonth' && prepaymentMonths == null) prepaymentMonths = 2;
|
|
122
|
+
|
|
123
|
+
let utilities = null;
|
|
124
|
+
for (const entry of Object.values(UTILITY_TERMS)) {
|
|
125
|
+
const matched = findCanonical(text, [entry], { partial: true });
|
|
126
|
+
if (matched) { utilities = matched.canonical; break; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const noCommission = SELLER_TERMS.noCommission && findCanonical(text, [SELLER_TERMS.noCommission], { partial: true });
|
|
130
|
+
const commissionPercent = toNumber(text.match(/(?:комисси\p{L}*|commission|comision|komissiya)[^\d%]{0,16}(\d{1,3}(?:[.,]\d+)?)\s*%/iu)?.[1]);
|
|
131
|
+
const commissionMentioned = SELLER_TERMS.commission && findCanonical(text, [SELLER_TERMS.commission], { partial: true });
|
|
132
|
+
|
|
133
|
+
return deepFreeze({
|
|
134
|
+
deposit: {
|
|
135
|
+
required: depositKind === 'noDeposit' ? false : depositKind ? true : null,
|
|
136
|
+
kind: depositKind,
|
|
137
|
+
amount: depositAmount.amount,
|
|
138
|
+
currency: depositAmount.currency,
|
|
139
|
+
},
|
|
140
|
+
prepaymentMonths,
|
|
141
|
+
utilities,
|
|
142
|
+
commission: {
|
|
143
|
+
required: noCommission ? false : (commissionMentioned || commissionPercent != null ? true : null),
|
|
144
|
+
percent: commissionPercent,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseHousingSeller(value) {
|
|
150
|
+
const text = normalizeUnicode(value ?? '');
|
|
151
|
+
if (!text) return deepFreeze({ type: null, confidence: 0 });
|
|
152
|
+
const owner = SELLER_TERMS.owner && findCanonical(text, [SELLER_TERMS.owner], { partial: true });
|
|
153
|
+
const agency = SELLER_TERMS.agency && findCanonical(text, [SELLER_TERMS.agency], { partial: true });
|
|
154
|
+
if (owner && !agency) return deepFreeze({ type: 'owner', confidence: 1 });
|
|
155
|
+
if (agency && !owner) return deepFreeze({ type: 'agency', confidence: 1 });
|
|
156
|
+
if (owner && agency) return deepFreeze({ type: null, confidence: 0.45 });
|
|
157
|
+
return deepFreeze({ type: null, confidence: 0 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function distanceFromWindow(window, entityOffset = 0) {
|
|
161
|
+
const candidates = [];
|
|
162
|
+
const minuteRe = /(\d{1,3})\s*(?:мин(?:ут[аы]?)?|minutes?|min\.?|daqiqa|минут|мин|минөт)/giu;
|
|
163
|
+
for (const match of window.matchAll(minuteRe)) {
|
|
164
|
+
const index = match.index ?? 0;
|
|
165
|
+
const local = window.slice(Math.max(0, index - 18), Math.min(window.length, index + match[0].length + 28));
|
|
166
|
+
const mode = /(?:пешком|walk(?:ing)?|on\s+foot|piyoda|жаяу)/iu.test(local)
|
|
167
|
+
? 'walk'
|
|
168
|
+
: /(?:на\s+машине|by\s+car|drive|mashinada|көлікпен)/iu.test(local) ? 'drive' : null;
|
|
169
|
+
candidates.push({ distance: Math.abs(index - entityOffset), value: Number(match[1]), unit: 'minute', mode });
|
|
170
|
+
}
|
|
171
|
+
const metricRe = /(\d{1,4}(?:[.,]\d+)?)\s*(км|km|километр\p{L}*|м|meter(?:s)?|метр\p{L}*)/giu;
|
|
172
|
+
for (const match of window.matchAll(metricRe)) {
|
|
173
|
+
const index = match.index ?? 0;
|
|
174
|
+
const rawUnit = match[2].toLocaleLowerCase();
|
|
175
|
+
candidates.push({ distance: Math.abs(index - entityOffset), value: toNumber(match[1]), unit: /км|km|километр/u.test(rawUnit) ? 'kilometer' : 'meter', mode: null });
|
|
176
|
+
}
|
|
177
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
178
|
+
if (!candidates.length) return null;
|
|
179
|
+
const { value, unit, mode } = candidates[0];
|
|
180
|
+
return { value, unit, mode };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function parseHousingInfrastructure(value) {
|
|
184
|
+
const text = normalizeUnicode(value ?? '');
|
|
185
|
+
if (!text) return [];
|
|
186
|
+
const matches = findAllCanonical(text, GENERIC_LANDMARK_TERMS);
|
|
187
|
+
const out = [];
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const match of matches) {
|
|
190
|
+
const key = `${match.canonical}:${match.start}`;
|
|
191
|
+
if (seen.has(key)) continue;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
const left = Math.max(0, match.start - 80);
|
|
194
|
+
const right = Math.min(text.length, match.end + 80);
|
|
195
|
+
const window = text.slice(left, right);
|
|
196
|
+
out.push(deepFreeze({
|
|
197
|
+
poi: match.canonical,
|
|
198
|
+
relation: findCanonical(window, LOCATION_RELATIONS, { partial: true })?.canonical || null,
|
|
199
|
+
distance: distanceFromWindow(window, match.start - left),
|
|
200
|
+
start: match.start,
|
|
201
|
+
end: match.end,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
return Object.freeze(out);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function parseHousingStructured(value) {
|
|
208
|
+
const text = String(value ?? '');
|
|
209
|
+
return deepFreeze({
|
|
210
|
+
intent: resolveHousingIntent(text),
|
|
211
|
+
context: parseHousingContext(text),
|
|
212
|
+
rooms: parseHousingRoomCount(text),
|
|
213
|
+
floor: parseHousingFloor(text),
|
|
214
|
+
area: parseHousingAreas(text),
|
|
215
|
+
payments: parseHousingPayments(text),
|
|
216
|
+
seller: parseHousingSeller(text),
|
|
217
|
+
infrastructure: parseHousingInfrastructure(text),
|
|
218
|
+
});
|
|
219
|
+
}
|