@whiteslove/parsing-lexicon 0.6.15 → 0.6.16
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 +1 -1
- package/src/housing-address.js +6 -0
- package/src/housing-card-fields.js +1 -1
- package/src/housing-listing-enrichment.d.ts +4 -1
- package/src/housing-listing-enrichment.js +6 -4
- package/src/housing-listing-fields.d.ts +1 -1
- package/src/housing-listing-fields.js +17 -4
- package/src/housing-money.js +9 -0
- package/src/housing-structured.js +9 -3
- package/src/housing-text.js +2 -1
- package/src/ua-kharkiv-location-translations.js +2 -0
- package/src/ua-kharkiv-microdistrict-extensions.js +1 -0
package/package.json
CHANGED
package/src/housing-address.js
CHANGED
|
@@ -39,6 +39,12 @@ function compactStreet(value) {
|
|
|
39
39
|
// "проживания" -> "оживания").
|
|
40
40
|
.replace(new RegExp(`^${PREFIX_STREET_MARKER}(?!\\p{L})\\s*`, 'iu'), '')
|
|
41
41
|
.replace(new RegExp(`\\s+${POSTFIX_STREET_MARKER}$`, 'iu'), '')
|
|
42
|
+
// OCR frequently substitutes “оя” for the Ukrainian/Russian “ля” in
|
|
43
|
+
// “шлях”; correct the street-token typo before canonical lookup.
|
|
44
|
+
.replace(/(?<!\p{L})шоях(?!\p{L})/giu, 'шлях')
|
|
45
|
+
// Listings commonly abbreviate this Kharkiv street to an initial and
|
|
46
|
+
// surname; expand it so address-only consumers do not retain just “Л”.
|
|
47
|
+
.replace(/(?<!\p{L})л\s*\.\s*малой(?!\p{L})/giu, 'Любови Малой')
|
|
42
48
|
.replace(/[\s,;:.\-–—]+$/gu, '')
|
|
43
49
|
.trim() || null;
|
|
44
50
|
}
|
|
@@ -51,7 +51,7 @@ function enumeratedNearby(text) {
|
|
|
51
51
|
const items = [];
|
|
52
52
|
for (const item of blocks.join(', ').split(/[,;•·|]+/).map((part) => part
|
|
53
53
|
.replace(/\s{2,}/g, ' ')
|
|
54
|
-
.replace(/^[\s\-–—.]+|[\s\-–—.!]+$/g, '')
|
|
54
|
+
.replace(/^[\s\-–—."'«»“”„]+|[\s\-–—.!"'«»“”„]+$/g, '')
|
|
55
55
|
.trim())) {
|
|
56
56
|
if (item.length < 3 || item.length > 45 || NEARBY_NOISE_RE.test(item) || NEARBY_FILLER_RE.test(item)) continue;
|
|
57
57
|
if (!/\p{L}{3,}/u.test(item)) continue;
|
|
@@ -71,4 +71,7 @@ export function parseHousingCommissionAmount(value: unknown): HousingCommissionA
|
|
|
71
71
|
export function parseHousingPerPersonPrice(value: unknown, options?: { country?: string }): HousingPerPersonPrice | null;
|
|
72
72
|
export function parseHousingTransitRoutes(value: unknown): readonly string[];
|
|
73
73
|
export function parseHousingObservedAmenities(value: unknown): readonly string[];
|
|
74
|
-
export function parseHousingListingEnrichment(
|
|
74
|
+
export function parseHousingListingEnrichment(
|
|
75
|
+
value: unknown,
|
|
76
|
+
options?: { country?: string; dealType?: 'sale' | 'longRent' | 'shortRent' | null },
|
|
77
|
+
): Readonly<HousingListingEnrichment>;
|
|
@@ -9,6 +9,7 @@ import { parseHousingListingFields } from './housing-listing-fields.js';
|
|
|
9
9
|
import { parseHousingResidentialComplex } from './housing-text.js';
|
|
10
10
|
import { parseHousingAddress } from './housing-address.js';
|
|
11
11
|
import { HOUSING_LANDMARK_EXTENSIONS, HOUSING_POI_EXTENSIONS } from './housing-poi-extensions.js';
|
|
12
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
12
13
|
|
|
13
14
|
const GENERIC_CATEGORY = Object.freeze({
|
|
14
15
|
Park: 'park', Metro: 'metro', 'Bus stop': 'transport', 'Public transport': 'transport', 'Main road': 'transport',
|
|
@@ -229,10 +230,11 @@ function walkMinutes(text) {
|
|
|
229
230
|
return Number.isInteger(value) && value > 0 && value <= 180 ? value : null;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
233
|
+
export function parseHousingListingEnrichment(value, { country = '', dealType = null } = {}) {
|
|
233
234
|
const text = normalizeUnicode(value ?? '');
|
|
234
235
|
if (!text) return deepFreeze({});
|
|
235
|
-
const
|
|
236
|
+
const resolvedDealType = dealType || resolveHousingIntent(text)?.dealType || null;
|
|
237
|
+
const listingFields = parseHousingListingFields(text, { country, dealType: resolvedDealType });
|
|
236
238
|
const floor = parseHousingFloor(text);
|
|
237
239
|
const areas = parseHousingAreas(text);
|
|
238
240
|
const audience = parseHousingAudience(text);
|
|
@@ -271,7 +273,7 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
271
273
|
euroLayout: listingFields.euroLayout ?? null,
|
|
272
274
|
gas: listingFields.gas ?? null,
|
|
273
275
|
newBuilding: listingFields.newBuilding ?? null,
|
|
274
|
-
communalSeparated: listingFields.communalSeparated ?? null,
|
|
276
|
+
communalSeparated: resolvedDealType === 'sale' ? null : (listingFields.communalSeparated ?? null),
|
|
275
277
|
parking: listingFields.parking ?? null,
|
|
276
278
|
elevator: listingFields.elevator ?? null,
|
|
277
279
|
heating: listingFields.heating ?? null,
|
|
@@ -284,7 +286,7 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
|
|
|
284
286
|
furnished: listingFields.furnished ?? null,
|
|
285
287
|
deposit: listingFields.depositRequired ?? null,
|
|
286
288
|
firstRental: firstRental(text, listingFields),
|
|
287
|
-
utilitiesAmount: listingFields.utilitiesAmount ?? null,
|
|
289
|
+
utilitiesAmount: resolvedDealType === 'sale' ? null : (listingFields.utilitiesAmount ?? null),
|
|
288
290
|
commission,
|
|
289
291
|
commissionPercent: commission === false ? 0 : null,
|
|
290
292
|
commissionAmount,
|
|
@@ -53,5 +53,5 @@ export type HousingListingFields = Readonly<{
|
|
|
53
53
|
*/
|
|
54
54
|
export function parseHousingListingFields(
|
|
55
55
|
value: unknown,
|
|
56
|
-
options?: { country?: string },
|
|
56
|
+
options?: { country?: string; dealType?: 'sale' | 'longRent' | 'shortRent' | null },
|
|
57
57
|
): Readonly<Partial<HousingListingFields>>;
|
|
@@ -2,6 +2,7 @@ import { deepFreeze } from './lexicon-core.js';
|
|
|
2
2
|
import { normalizeUnicode } from './normalization.js';
|
|
3
3
|
import { parseHousingContext } from './housing-context.js';
|
|
4
4
|
import { parseHousingFeatures } from './housing-features.js';
|
|
5
|
+
import { resolveHousingIntent } from './housing-intent.js';
|
|
5
6
|
|
|
6
7
|
const bool = (text, positive, negative = null) => {
|
|
7
8
|
if (negative?.test(text)) return false;
|
|
@@ -69,6 +70,11 @@ function parseAvailableFrom(text) {
|
|
|
69
70
|
const match = text.match(/(?:доступн\p{L}*\s+с|свободн\p{L}*\s+с|заселени\p{L}*\s+с|заезд\s+с|available\s+from|move[- ]?in\s+from)\s*[:\-]?\s*((?:\d{1,2}[./-]\d{1,2}(?:[./-]\d{2,4})?)|(?:\d{1,2}\s+[\p{L}]{3,12}(?:\s+\d{4})?))/iu);
|
|
70
71
|
if (match?.[1]) return match[1].trim();
|
|
71
72
|
|
|
73
|
+
// Ukrainian listings commonly phrase this as “move-in is possible only
|
|
74
|
+
// from …”, rather than using the shorter “available from …” form.
|
|
75
|
+
const ukrainianMoveIn = text.match(/(?:перегляд\s+(?:і|та)\s+)?заселен\p{L}*\s+можлив\p{L}*(?:\s+лише)?\s+з\s*((?:\d{1,2}[./-]\d{1,2}(?:[./-]\d{2,4})?)|(?:\d{1,2}\s+[\p{L}]{3,12}(?:\s+\d{4})?))/iu);
|
|
76
|
+
if (ukrainianMoveIn?.[1]) return ukrainianMoveIn[1].trim();
|
|
77
|
+
|
|
72
78
|
// Uzbek listings commonly attach the ablative suffix directly to the month:
|
|
73
79
|
// "1-sentyabrdan beriladi" / "1-сентябрдан берилади".
|
|
74
80
|
const uzbek = text.match(/(\d{1,2}\s*[-./]?\s*[\p{L}]{3,12})(?:dan|дан)(?=$|[^\p{L}\p{N}_])[^.\r\n]{0,32}(?:beriladi|берилади|bo['’]?sh|бўш)/iu);
|
|
@@ -76,7 +82,9 @@ function parseAvailableFrom(text) {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
function parseUtilitiesAmount(text) {
|
|
79
|
-
|
|
85
|
+
// Do not bridge a sentence boundary. Otherwise “plus utilities. Tel. 06…”
|
|
86
|
+
// turns the first phone digits into a fictitious utility bill.
|
|
87
|
+
const match = text.match(/(?:коммунальн\p{L}*|коммуналк\p{L}*|ком\.?\s*услуг\p{L}*|utilities?|bills?)[^\d.;!?…\r\n]{0,24}(?:около|примерно|~|≈)?\s*(\d{2,8}(?:[.,]\d{1,2})?)\s*(₴|грн|uah|\$|usd|€|eur|сум|uzs|тг|kzt|lei|ron|руб|rub)?/iu);
|
|
80
88
|
if (!match) return null;
|
|
81
89
|
const amount = Number(match[1].replace(',', '.'));
|
|
82
90
|
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
@@ -103,11 +111,16 @@ function parseDepositRequired(text) {
|
|
|
103
111
|
return null;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
114
|
+
export function parseHousingListingFields(value, { country = '', dealType = null } = {}) {
|
|
107
115
|
const text = normalizeUnicode(value ?? '');
|
|
108
116
|
const context = parseHousingContext(text);
|
|
109
117
|
const features = parseHousingFeatures(text);
|
|
110
118
|
if (!text) return deepFreeze({});
|
|
119
|
+
// Utility billing is a rental-only concept. A source may carry a generic
|
|
120
|
+
// payment-table value on every listing, including sales, so never let that
|
|
121
|
+
// value become a persisted housing attribute for a sale.
|
|
122
|
+
const resolvedDealType = dealType || resolveHousingIntent(text)?.dealType || null;
|
|
123
|
+
const isSale = resolvedDealType === 'sale';
|
|
111
124
|
|
|
112
125
|
const gas = bool(text,
|
|
113
126
|
/(?:^|[^\p{L}\p{N}_])(?:газ|gaz|gas)(?=$|[^\p{L}\p{N}_])|метан|aragaz|gaz\s+ta['’]?min/iu,
|
|
@@ -211,7 +224,7 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
|
211
224
|
),
|
|
212
225
|
gas,
|
|
213
226
|
newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s+built|yangi\s+(?:bino|qurilgan|uy)|bloc\s+nou/iu),
|
|
214
|
-
communalSeparated: parseCommunalSeparated(text, country),
|
|
227
|
+
communalSeparated: isSale ? null : parseCommunalSeparated(text, country),
|
|
215
228
|
parking: bool(
|
|
216
229
|
text,
|
|
217
230
|
/паркинг|парков|машино[- ]?мест|parking|avtoturargoh|mashina\s*joyi/iu,
|
|
@@ -239,6 +252,6 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
|
239
252
|
firstRent,
|
|
240
253
|
minRentTerm: parseMinRentTerm(text),
|
|
241
254
|
availableFrom: parseAvailableFrom(text),
|
|
242
|
-
utilitiesAmount: parseUtilitiesAmount(text),
|
|
255
|
+
utilitiesAmount: isSale ? null : parseUtilitiesAmount(text),
|
|
243
256
|
});
|
|
244
257
|
}
|
package/src/housing-money.js
CHANGED
|
@@ -242,8 +242,17 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
242
242
|
// `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
|
|
243
243
|
const match = priceText.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
|
|
244
244
|
if (match) {
|
|
245
|
+
// Ukrainian listings abbreviate a microdistrict as “531 м/р”. The
|
|
246
|
+
// one-letter million scale is otherwise valid, but this trailing
|
|
247
|
+
// “/р” makes the token an area identifier, never a price.
|
|
248
|
+
const tail = priceText.slice((match.index ?? 0) + match[0].length);
|
|
249
|
+
if (/^\s*\/\s*[рr](?=$|[^\p{L}\p{N}_])/iu.test(tail)) {
|
|
250
|
+
// Fall through to the ordinary bare-number pass, which can still
|
|
251
|
+
// recover a genuine rent amount later in the listing.
|
|
252
|
+
} else {
|
|
245
253
|
const amount = parseScaledAmount(match[1], match[2]);
|
|
246
254
|
if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
|
|
255
|
+
}
|
|
247
256
|
}
|
|
248
257
|
}
|
|
249
258
|
|
|
@@ -430,6 +430,12 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
430
430
|
const country = String(options.country || '').trim();
|
|
431
431
|
const fallbackCurrency = options.fallbackCurrency || countryCurrency(country) || '';
|
|
432
432
|
const phoneCountry = options.phoneCountry || countryPhoneHint(country) || null;
|
|
433
|
+
const intent = resolveHousingIntent(text);
|
|
434
|
+
const listingFields = parseHousingListingFields(text, { country, dealType: intent?.dealType || null });
|
|
435
|
+
const parsedPayments = parseHousingPaymentDetails(text);
|
|
436
|
+
const payments = intent?.dealType === 'sale'
|
|
437
|
+
? deepFreeze({ ...parsedPayments, utilities: null })
|
|
438
|
+
: parsedPayments;
|
|
433
439
|
|
|
434
440
|
return deepFreeze({
|
|
435
441
|
text,
|
|
@@ -437,7 +443,7 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
437
443
|
platform: sourcePost.source,
|
|
438
444
|
contact: sourcePost.contact,
|
|
439
445
|
},
|
|
440
|
-
intent
|
|
446
|
+
intent,
|
|
441
447
|
context: parseHousingContext(text),
|
|
442
448
|
rooms: parseHousingRoomCount(text),
|
|
443
449
|
floor: parseHousingFloor(text),
|
|
@@ -450,8 +456,8 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
450
456
|
}),
|
|
451
457
|
residentialComplex: parseHousingResidentialComplex(text),
|
|
452
458
|
amenities: parseHousingAmenities(text),
|
|
453
|
-
listingFields
|
|
454
|
-
payments
|
|
459
|
+
listingFields,
|
|
460
|
+
payments,
|
|
455
461
|
seller: parseHousingSeller(text),
|
|
456
462
|
infrastructure: parseHousingInfrastructure(text),
|
|
457
463
|
contacts: parseHousingContacts(text, { countryHint: phoneCountry, sourcePost }),
|
package/src/housing-text.js
CHANGED
|
@@ -33,6 +33,7 @@ const RC_ATTRIBUTE_TOKEN_RE = /^\d|^[\d/]+$|^[a-zа-яё]?\d+(?:[а-яёa-z]{1,4
|
|
|
33
33
|
const RC_LATIN_NOISE_RE = /^(?:vip|lux|luxe|elite|premium|euro|evro|new|top|super|best|hot|urgent|srochno|arenda|ijara|sotiladi|sale|rent|for|home|house|flat|apartment|apartments|kvartira|tashkent|toshkent|wifi|wi|fi|tv|ac|internet|telegram|yandex|google|instagram|whatsapp|iphone|samsung|lg|bosch|artel|km|km2|m2|sqm|usd|uzs|eur)$/iu;
|
|
34
34
|
const RC_LATIN_WORD_RE = /^[A-Za-z][A-Za-z'’\-]{1,}$/u;
|
|
35
35
|
const RC_DISTRICT_MARKER_RE = /^(?:район|районе|районы|туман|тумани|tumani|district)$/iu;
|
|
36
|
+
const RC_ADDRESS_INTRO_RE = /^(?:за|адрес(?:ою|у|ом)?|вул(?:иця)?|улица)$/iu;
|
|
36
37
|
const RC_MARKER_RE = /(?:жк|жм|ж\/к|residential complex|ansamblu(?: rezidential)?|turar[- ]?joy majmuasi)\s*/i;
|
|
37
38
|
|
|
38
39
|
function leadingLatinName(text) {
|
|
@@ -65,7 +66,7 @@ export function parseHousingResidentialComplex(value) {
|
|
|
65
66
|
const parsed = String(candidate).trim().replace(/([\p{Ll}\d])(\p{Lu})/gu, '$1 $2').replace(/\s{2,}/g, ' ').split(/\s+/).reduce((words, token) => {
|
|
66
67
|
if (words.stopped || words.list.length >= 4) return { ...words, stopped: true };
|
|
67
68
|
const clean = token.replace(/^[«»"'„“]+|[«»"'„“!|,]+$/g, '');
|
|
68
|
-
if (!clean || RC_STOP_WORD_RE.test(clean) || RC_ATTRIBUTE_TOKEN_RE.test(clean)) return { ...words, stopped: true, by: clean };
|
|
69
|
+
if (!clean || RC_STOP_WORD_RE.test(clean) || RC_ADDRESS_INTRO_RE.test(clean) || RC_ATTRIBUTE_TOKEN_RE.test(clean)) return { ...words, stopped: true, by: clean };
|
|
69
70
|
return { list: [...words.list, clean], stopped: false, by: words.by };
|
|
70
71
|
}, { list: [], stopped: false, by: '' });
|
|
71
72
|
const words = RC_DISTRICT_MARKER_RE.test(parsed.by) ? parsed.list.slice(0, -1) : parsed.list;
|
|
@@ -39,6 +39,8 @@ export const UA_KHARKIV_LOCATION_TRANSLATIONS = Object.freeze({
|
|
|
39
39
|
['Horizont', 'Обрій', 'Обрий', 'Obrii', 'Горизонт'],
|
|
40
40
|
]),
|
|
41
41
|
streets: locationEntries([
|
|
42
|
+
['Poltavskyi Shliakh Street', 'Полтавський шлях', 'Полтавский шлях', 'Полтавський шоях', 'Полтавский шоях'],
|
|
43
|
+
['Liubovi Maloi Street', 'вулиця Любові Малої', 'вул. Любові Малої', 'Любові Малої', 'улица Любови Малой', 'ул. Любови Малой', 'Любови Малой', 'Л. Малой', 'Л.Малой', 'ул. Л. Малой', 'ул.Л.Малой', 'улица Л. Малой', 'улица Л.Малой'],
|
|
42
44
|
['Hvardiitsiv-Shyronintsiv Street', 'вулиця Гвардійців-Широнінців', 'вул. Гвардійців-Широнінців', 'Гвардійців-Широнінців', 'улица Гвардейцев-Широнинцев', 'ул. Гвардейцев-Широнинцев', 'Гвардейцев-Широнинцев', 'Гвардейцев Широнинцев'],
|
|
43
45
|
['Yuvileinyi Avenue', 'проспект Ювілейний', 'пр-т Ювілейний', 'Ювілейний проспект', 'проспект Юбилейный', 'пр-т Юбилейный', 'Юбилейный проспект', 'проспект 50-річчя ВЛКСМ', 'проспект 50-летия ВЛКСМ'],
|
|
44
46
|
['Traktorobudivnykiv Avenue', 'проспект Тракторобудівників', 'пр-т Тракторобудівників', 'Тракторобудівників проспект', 'проспект Тракторостроителей', 'пр-т Тракторостроителей', 'Тракторостроителей проспект'],
|
|
@@ -3,6 +3,7 @@ import { locationEntries } from './location-merge.js';
|
|
|
3
3
|
export const UA_KHARKIV_MICRODISTRICT_EXTENSIONS = Object.freeze({
|
|
4
4
|
Kharkiv: Object.freeze({
|
|
5
5
|
microdistricts: locationEntries([
|
|
6
|
+
['Kholodna Hora', 'Хол. Гора', 'Хол Гора'],
|
|
6
7
|
['337 microdistrict', '337-й мікрорайон', '337 мікрорайон', '337-й микрорайон', '337 микрорайон', '337 мкр', '337 мкр.', '337-й мкр', '337 м/р', '337 мрн'],
|
|
7
8
|
['339 microdistrict', '339-й мікрорайон', '339 мікрорайон', '339-й микрорайон', '339 микрорайон', '339 мкр', '339 мкр.', '339-й мкр', '339 м/р', '339 мрн'],
|
|
8
9
|
]),
|