@whiteslove/parsing-lexicon 0.2.0
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 -0
- package/README.md +137 -0
- package/index.d.ts +375 -0
- package/package.json +75 -0
- package/src/central-asia-locations.js +211 -0
- package/src/central-asia.js +233 -0
- package/src/contact.d.ts +44 -0
- package/src/contact.js +159 -0
- package/src/countries.js +175 -0
- package/src/country-context.d.ts +10 -0
- package/src/country-context.js +22 -0
- package/src/currency.d.ts +14 -0
- package/src/currency.js +66 -0
- package/src/geo.js +169 -0
- package/src/geography-central-asia.js +42 -0
- package/src/geography-detection.d.ts +14 -0
- package/src/geography-detection.js +64 -0
- package/src/geography-display.d.ts +13 -0
- package/src/geography-display.js +113 -0
- package/src/geography.js +210 -0
- package/src/hiring-advanced.js +147 -0
- package/src/hiring-candidate-fields.d.ts +9 -0
- package/src/hiring-candidate-fields.js +92 -0
- package/src/hiring-context.d.ts +64 -0
- package/src/hiring-context.js +412 -0
- package/src/hiring-language-extensions.d.ts +19 -0
- package/src/hiring-language-extensions.js +139 -0
- package/src/hiring-languages.js +135 -0
- package/src/hiring-location-fields.d.ts +3 -0
- package/src/hiring-location-fields.js +53 -0
- package/src/hiring-professions-ro.js +191 -0
- package/src/hiring-professions.js +437 -0
- package/src/hiring-requirements.d.ts +16 -0
- package/src/hiring-requirements.js +170 -0
- package/src/hiring-salary-context.d.ts +27 -0
- package/src/hiring-salary-context.js +76 -0
- package/src/hiring-semantics.d.ts +29 -0
- package/src/hiring-semantics.js +218 -0
- package/src/hiring-skills.d.ts +21 -0
- package/src/hiring-skills.js +315 -0
- package/src/hiring-source-aliases.d.ts +32 -0
- package/src/hiring-source-aliases.js +186 -0
- package/src/hiring-source-semantics.d.ts +31 -0
- package/src/hiring-source-semantics.js +293 -0
- package/src/hiring-temporal.d.ts +15 -0
- package/src/hiring-temporal.js +67 -0
- package/src/hiring-vacancy-fields.d.ts +5 -0
- package/src/hiring-vacancy-fields.js +23 -0
- package/src/hiring-work-semantics.d.ts +34 -0
- package/src/hiring-work-semantics.js +72 -0
- package/src/hiring.js +180 -0
- package/src/housing-address.d.ts +18 -0
- package/src/housing-address.js +210 -0
- package/src/housing-context.d.ts +36 -0
- package/src/housing-context.js +183 -0
- package/src/housing-features.js +30 -0
- package/src/housing-intent.d.ts +9 -0
- package/src/housing-intent.js +132 -0
- package/src/housing-listing-fields.js +131 -0
- package/src/housing-money.d.ts +7 -0
- package/src/housing-money.js +102 -0
- package/src/housing-source-aliases.d.ts +8 -0
- package/src/housing-source-aliases.js +51 -0
- package/src/housing-structured.d.ts +55 -0
- package/src/housing-structured.js +219 -0
- package/src/housing.js +200 -0
- package/src/index.js +44 -0
- package/src/kz-location-extensions.js +307 -0
- package/src/landmarks.js +21 -0
- package/src/lexicon-core.js +58 -0
- package/src/location-merge.js +106 -0
- package/src/locations.js +456 -0
- package/src/money-core.d.ts +10 -0
- package/src/money-core.js +81 -0
- package/src/money-lexicon.d.ts +5 -0
- package/src/money-lexicon.js +79 -0
- package/src/money.js +154 -0
- package/src/normalization.js +384 -0
- package/src/odesa-metropolitan.js +145 -0
- package/src/romania-geography.js +47 -0
- package/src/tashkent-colloquial.js +72 -0
- package/src/tashkent-housing-geography.d.ts +38 -0
- package/src/tashkent-housing-geography.js +211 -0
- package/src/tashkent-pois.js +215 -0
- package/src/tashkent-residential-complexes.js +272 -0
- package/src/ua-location-extensions-major.js +118 -0
- package/src/ua-location-extensions-metro.js +9 -0
- package/src/ua-location-extensions-regional.js +246 -0
- package/src/ua-secondary-cities.d.ts +2 -0
- package/src/ua-secondary-cities.js +57 -0
- package/src/ukraine.js +75 -0
- package/src/uz-location-extensions.js +259 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { aliasesOf, findCanonical } from './normalization.js';
|
|
2
|
+
import { CURRENCY_TERMS, NUMBER_MULTIPLIERS } from './money-lexicon.js';
|
|
3
|
+
import {
|
|
4
|
+
MONEY_NUMBER_PATTERN,
|
|
5
|
+
moneyCurrencyFromText,
|
|
6
|
+
moneyCurrencyPattern,
|
|
7
|
+
parseNumericAmount,
|
|
8
|
+
parseScaledAmount,
|
|
9
|
+
} from './money-core.js';
|
|
10
|
+
import { maskPhoneLikeSpans } from './contact.js';
|
|
11
|
+
|
|
12
|
+
const PRICE_KEYWORD = '(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|аренд(?:а|ная\\s+плата)?|rent)';
|
|
13
|
+
const PRICE_CURRENCY = `(?:${moneyCurrencyPattern()})`;
|
|
14
|
+
|
|
15
|
+
function escapeRegex(value) {
|
|
16
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
20
|
+
const original = String(value || '');
|
|
21
|
+
if (!original) return Object.freeze({ price: null, currency: fallbackCurrency || '' });
|
|
22
|
+
|
|
23
|
+
// Contact spans are removed once, before every money branch. A phone can
|
|
24
|
+
// therefore never win as a labelled, currency-tagged or fallback amount.
|
|
25
|
+
const text = maskPhoneLikeSpans(original);
|
|
26
|
+
let currency = moneyCurrencyFromText(text, fallbackCurrency || '') || '';
|
|
27
|
+
const explicit = Boolean(findCanonical(text, CURRENCY_TERMS, { partial: true }));
|
|
28
|
+
let price = null;
|
|
29
|
+
|
|
30
|
+
// Common Ukrainian/Russian classifieds shorthand: "10 т грн" / "10 т гр".
|
|
31
|
+
// Keep this housing-specific instead of adding globally ambiguous aliases
|
|
32
|
+
// `т` (tonne) and `гр` (gram) to the shared money lexicon. A nearby price
|
|
33
|
+
// keyword plus an explicit hryvnia shorthand makes the intent unambiguous.
|
|
34
|
+
const compactThousandUah = text.match(new RegExp(
|
|
35
|
+
`${PRICE_KEYWORD}[^\\r\\n]{0,48}?(\\d{1,6}(?:[.,]\\d{1,2})?)\\s*т(?:ыс\\.?)?\\s*(?:гр(?:н)?|₴|uah)(?=$|[^\\p{L}\\p{N}_])`,
|
|
36
|
+
'iu',
|
|
37
|
+
));
|
|
38
|
+
if (compactThousandUah) {
|
|
39
|
+
const amount = parseNumericAmount(compactThousandUah[1]);
|
|
40
|
+
if (amount != null && amount >= 1 && amount <= 5_000_000) {
|
|
41
|
+
price = Math.round(amount * 1000);
|
|
42
|
+
currency = 'UAH';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const labelled = text.match(new RegExp(`${PRICE_KEYWORD}\\s*[:\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})`, 'i'));
|
|
47
|
+
if (price == null && labelled) {
|
|
48
|
+
const amount = parseNumericAmount(labelled[1]);
|
|
49
|
+
if (amount != null && amount >= 50 && amount <= 5_000_000_000) price = amount;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (price == null) {
|
|
53
|
+
let tagged = null;
|
|
54
|
+
const reNumSym = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*${PRICE_CURRENCY}`, 'ig');
|
|
55
|
+
const reSymNum = new RegExp(`${PRICE_CURRENCY}\\s*(${MONEY_NUMBER_PATTERN})`, 'ig');
|
|
56
|
+
for (const regex of [reNumSym, reSymNum]) {
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = regex.exec(text)) !== null) {
|
|
59
|
+
const amount = parseNumericAmount(match[1]);
|
|
60
|
+
if (amount != null && amount >= 50 && amount <= 5_000_000_000 && (tagged == null || amount > tagged)) tagged = amount;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
price = tagged;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (price == null) {
|
|
67
|
+
const scalePattern = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
|
|
68
|
+
.sort((a, b) => String(b).length - String(a).length)
|
|
69
|
+
.map(escapeRegex)
|
|
70
|
+
.join('|');
|
|
71
|
+
// Multiplier aliases include useful one-letter forms such as `m` and `k`.
|
|
72
|
+
// Require the alias to end at a token boundary so measurements/words like
|
|
73
|
+
// `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
|
|
74
|
+
const match = text.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${scalePattern})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
|
|
75
|
+
if (match) {
|
|
76
|
+
const amount = parseScaledAmount(match[1], match[2]);
|
|
77
|
+
if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (price == null) {
|
|
82
|
+
const matches = text.match(/\d{1,3}(?:[ \u00A0.,]\d{3})+|\d{4,}/g) || [];
|
|
83
|
+
let best = null;
|
|
84
|
+
for (const raw of matches) {
|
|
85
|
+
const digits = raw.replace(/[\s.,]/g, '');
|
|
86
|
+
if (digits[0] === '0') continue;
|
|
87
|
+
const amount = parseNumericAmount(raw);
|
|
88
|
+
if (amount != null && amount >= 1000 && amount <= 5_000_000_000 && (best == null || amount > best)) best = amount;
|
|
89
|
+
}
|
|
90
|
+
price = best;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!explicit && fallbackCurrency === 'UZS' && price != null) {
|
|
94
|
+
const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(text);
|
|
95
|
+
currency = price >= 1_000_000 || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Object.freeze({ price, currency });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Compatibility name for callers migrating from Flat Finder's local parser.
|
|
102
|
+
export const parsePriceFromText = parseHousingPrice;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const HOUSING_DEAL_TYPE_EXTENSIONS: readonly unknown[];
|
|
2
|
+
export const HOUSING_ROOM_ONLY_EXTENSIONS: readonly unknown[];
|
|
3
|
+
export function resolveExtendedHousingIntent(value: unknown): Readonly<{
|
|
4
|
+
action: string | null;
|
|
5
|
+
listingKind: string | null;
|
|
6
|
+
dealType: 'sale' | 'longRent' | 'shortRent';
|
|
7
|
+
}> | null;
|
|
8
|
+
export function isRoomOnlyHousing(value: unknown): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { lexiconEntity } from './lexicon-core.js';
|
|
2
|
+
import { findCanonical } from './normalization.js';
|
|
3
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
4
|
+
import { HOUSING_OCCUPANCY_TYPES, PROPERTY_TYPES } from './housing.js';
|
|
5
|
+
|
|
6
|
+
const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
|
|
7
|
+
|
|
8
|
+
/** Source/feed wording that is useful for normalization but too noisy for the generic core. */
|
|
9
|
+
export const HOUSING_DEAL_TYPE_EXTENSIONS = Object.freeze([
|
|
10
|
+
group('longRent', {
|
|
11
|
+
ru: ['снять', 'в месяц', 'месяц'],
|
|
12
|
+
en: ['monthly'],
|
|
13
|
+
uzLatn: ['ijaraga', 'oyiga', 'beriladi'],
|
|
14
|
+
uzCyrl: ['ижарага', 'ойига', 'берилади'],
|
|
15
|
+
kk: ['айына'],
|
|
16
|
+
}),
|
|
17
|
+
group('shortRent', {
|
|
18
|
+
ru: ['сутки', 'суток', 'суточно'],
|
|
19
|
+
en: ['daily'],
|
|
20
|
+
uzLatn: ['kunlik', 'sutkalik'],
|
|
21
|
+
uzCyrl: ['кунлик', 'суткалик'],
|
|
22
|
+
kk: ['тәуліктік'],
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const HOUSING_ROOM_ONLY_EXTENSIONS = Object.freeze([
|
|
27
|
+
group('roomOnly', {
|
|
28
|
+
ru: ['шеринг'],
|
|
29
|
+
en: ['room only'],
|
|
30
|
+
uzLatn: ['student qizlarga', 'talaba qizlarga', 'opshijit dom', 'obshijit dom'],
|
|
31
|
+
uzCyrl: ['студент қизларга', 'талаба қизларга'],
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function resolveExtendedHousingIntent(value) {
|
|
36
|
+
const text = String(value || '');
|
|
37
|
+
const base = resolveHousingIntent(text);
|
|
38
|
+
if (base) return base;
|
|
39
|
+
const deal = findCanonical(text, HOUSING_DEAL_TYPE_EXTENSIONS, { partial: true });
|
|
40
|
+
if (!deal?.canonical) return null;
|
|
41
|
+
return Object.freeze({ action: null, listingKind: null, dealType: deal.canonical });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isRoomOnlyHousing(value) {
|
|
45
|
+
const text = String(value || '');
|
|
46
|
+
if (!text.trim()) return false;
|
|
47
|
+
const occupancy = findCanonical(text, HOUSING_OCCUPANCY_TYPES, { partial: true })?.canonical;
|
|
48
|
+
if (occupancy && ['room', 'sharedRoom', 'bedSpace'].includes(occupancy)) return true;
|
|
49
|
+
if (findCanonical(text, PROPERTY_TYPES, { partial: true })?.canonical === 'dormitory') return true;
|
|
50
|
+
return Boolean(findCanonical(text, HOUSING_ROOM_ONLY_EXTENSIONS, { partial: true }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type HousingAreaDetails = Readonly<{
|
|
2
|
+
total: number | null;
|
|
3
|
+
living: number | null;
|
|
4
|
+
kitchen: number | null;
|
|
5
|
+
balcony: number | null;
|
|
6
|
+
terrace: number | null;
|
|
7
|
+
}>;
|
|
8
|
+
|
|
9
|
+
export type HousingPaymentContext = Readonly<{
|
|
10
|
+
deposit: Readonly<{
|
|
11
|
+
required: boolean | null;
|
|
12
|
+
kind: string | null;
|
|
13
|
+
amount: number | null;
|
|
14
|
+
currency: string | null;
|
|
15
|
+
}>;
|
|
16
|
+
prepaymentMonths: number | null;
|
|
17
|
+
utilities: string | null;
|
|
18
|
+
commission: Readonly<{
|
|
19
|
+
required: boolean | null;
|
|
20
|
+
percent: number | null;
|
|
21
|
+
}>;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
export type HousingInfrastructureDistance = Readonly<{
|
|
25
|
+
value: number | null;
|
|
26
|
+
unit: 'minute' | 'meter' | 'kilometer';
|
|
27
|
+
mode: 'walk' | 'drive' | null;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
export type HousingInfrastructureMatch = Readonly<{
|
|
31
|
+
poi: string;
|
|
32
|
+
relation: string | null;
|
|
33
|
+
distance: HousingInfrastructureDistance | null;
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type HousingStructuredResult = Readonly<{
|
|
39
|
+
intent: Readonly<{ action: string | null; listingKind: string | null; dealType: string }> | null;
|
|
40
|
+
context: Readonly<Record<string, unknown>>;
|
|
41
|
+
rooms: number | null;
|
|
42
|
+
floor: Readonly<{ floor: number | null; totalFloors: number | null }>;
|
|
43
|
+
area: HousingAreaDetails;
|
|
44
|
+
payments: HousingPaymentContext;
|
|
45
|
+
seller: Readonly<{ type: 'owner' | 'agency' | null; confidence: number }>;
|
|
46
|
+
infrastructure: readonly HousingInfrastructureMatch[];
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
export function parseHousingRoomCount(value: unknown): number | null;
|
|
50
|
+
export function parseHousingFloor(value: unknown): Readonly<{ floor: number | null; totalFloors: number | null }>;
|
|
51
|
+
export function parseHousingAreas(value: unknown): HousingAreaDetails;
|
|
52
|
+
export function parseHousingPayments(value: unknown): HousingPaymentContext;
|
|
53
|
+
export function parseHousingSeller(value: unknown): Readonly<{ type: 'owner' | 'agency' | null; confidence: number }>;
|
|
54
|
+
export function parseHousingInfrastructure(value: unknown): readonly HousingInfrastructureMatch[];
|
|
55
|
+
export function parseHousingStructured(value: unknown): HousingStructuredResult;
|
|
@@ -0,0 +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
|
+
}
|