@whiteslove/parsing-lexicon 0.10.3 → 0.10.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 +1 -1
- package/src/country-context.d.ts +1 -0
- package/src/country-context.js +11 -0
- package/src/housing-address.d.ts +2 -0
- package/src/housing-address.js +5 -0
- package/src/housing-commercial.js +1 -1
- package/src/housing-listing-enrichment.d.ts +2 -0
- package/src/housing-listing-enrichment.js +14 -2
- package/src/housing-listing-fields.js +2 -2
- package/src/housing-money.js +35 -2
- package/src/housing-structured.js +10 -2
- package/src/housing.js +2 -2
- package/src/normalization.js +6 -0
- package/src/tashkent-housing-geography.d.ts +1 -0
- package/src/tashkent-housing-geography.js +58 -25
package/package.json
CHANGED
package/src/country-context.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ export type CountryContext = Readonly<{
|
|
|
8
8
|
export function countryContext(value: unknown): CountryContext | null;
|
|
9
9
|
export function countryCurrency(value: unknown): string | null;
|
|
10
10
|
export function countryPhoneHint(value: unknown): string | null;
|
|
11
|
+
export function countryCodeForCity(city: unknown): string;
|
package/src/country-context.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { canonicalCountryCode, countryByCode } from './countries.js';
|
|
2
|
+
import { CITIES } from './geography.js';
|
|
3
|
+
import { findCanonical } from './normalization.js';
|
|
2
4
|
|
|
3
5
|
export function countryContext(value) {
|
|
4
6
|
const code = canonicalCountryCode(value);
|
|
@@ -20,3 +22,12 @@ export function countryCurrency(value) {
|
|
|
20
22
|
export function countryPhoneHint(value) {
|
|
21
23
|
return countryContext(value)?.phoneCountry || null;
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
// A context that names only the city ("Tashkent") still fixes the country and
|
|
27
|
+
// with it the default currency. Without this a Tashkent listing parsed with
|
|
28
|
+
// `{ city }` alone had no fallback currency and consumers filled the gap with
|
|
29
|
+
// their own default (KZT), mislabelling so'm prices.
|
|
30
|
+
export function countryCodeForCity(city) {
|
|
31
|
+
if (!city) return '';
|
|
32
|
+
return findCanonical(String(city), CITIES)?.country || '';
|
|
33
|
+
}
|
package/src/housing-address.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type HousingAddressParts = Readonly<{
|
|
|
10
10
|
staircase?: string;
|
|
11
11
|
district?: string;
|
|
12
12
|
metro?: string;
|
|
13
|
+
/** Every metro station named, primary (`metro`) first. */
|
|
14
|
+
metros?: readonly string[];
|
|
13
15
|
mahalla?: string;
|
|
14
16
|
geoEntities?: Readonly<Partial<Record<'street' | 'district' | 'metro' | 'mahalla' | 'residentialComplex', Readonly<{
|
|
15
17
|
id: string;
|
package/src/housing-address.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
TASHKENT_NUMBERED_AREA_ALIASES,
|
|
3
3
|
matchTashkentHousingDistrict,
|
|
4
4
|
matchTashkentHousingMetro,
|
|
5
|
+
matchTashkentHousingMetros,
|
|
5
6
|
matchTashkentNumberedArea,
|
|
6
7
|
} from './tashkent-housing-geography.js';
|
|
7
8
|
import { detectNonAddressSpans } from './semantic-spans.js';
|
|
@@ -246,10 +247,14 @@ function tashkentGeoComponents(value) {
|
|
|
246
247
|
const text = String(value ?? '');
|
|
247
248
|
const district = matchTashkentHousingDistrict(text)?.name || null;
|
|
248
249
|
const metro = matchTashkentHousingMetro(text)?.name || null;
|
|
250
|
+
const metros = metro
|
|
251
|
+
? [...new Set([metro, ...matchTashkentHousingMetros(text).map((station) => station.name)])]
|
|
252
|
+
: [];
|
|
249
253
|
const mahalla = text.match(/(?:^|[^\p{L}])(\p{L}[\p{L}'’ʼ-]{1,48})\s+(?:mahalla(?:si)?|маҳалла(?:си)?|махалл[ая]|mfy)(?=$|[^\p{L}])/iu)?.[1] || null;
|
|
250
254
|
return Object.freeze({
|
|
251
255
|
district,
|
|
252
256
|
metro,
|
|
257
|
+
metros: metros.length ? Object.freeze(metros) : null,
|
|
253
258
|
mahalla: DESCRIPTIVE_MAHALLA_WORD_RE.test(mahalla || '') ? null : compactStreet(mahalla),
|
|
254
259
|
});
|
|
255
260
|
}
|
|
@@ -50,7 +50,7 @@ export function looksParkingOnly(value) {
|
|
|
50
50
|
// classifyTelegramAgency in whiteslove.me-backend-platform's
|
|
51
51
|
// apps/flats/src/scrapers/telegram.js). If you're tempted to merge these,
|
|
52
52
|
// check that call site's expectations first.
|
|
53
|
-
const DIRECT_OWNER_RE = /(
|
|
53
|
+
const DIRECT_OWNER_RE = /(?:(?:makler|vositachi)(?:lar)?\s+bilan\s+ishla(?:n)?may\p{L}*|(?:makler|vositachi)(?:lar)?\s+(?:bezovta|bezota)\s+qilma\p{L}*|(?:с\s+)?(?:ри[еэ]лтор|маклер|агент(?:ств)?|посредник)\p{L}*\s+(?:просьба\s+)?не\s+(?:беспокоить|работа\p{L}*|звонить)|без\s+(?:макл(?:ер[а-яё]*)?|посредник[а-яё]*|ри[еэ]?лтор[а-яё]*|агент[а-яё]*)|от\s+(?:собственник[а-яё]*|хозяин[а-яё]*)|від\s+(?:власник[а-яіїґ]*|власниц[яії][а-яіїґ]*|господар[а-яіїґ]*)|без\s+(?:посередник[а-яіїґ]*|рі[єе]лтор[а-яіїґ]*|агент[а-яіїґ]*)|прямо\s+від\s+(?:власник[а-яіїґ]*|власниц[яії][а-яіїґ]*|господар[а-яіїґ]*)|(?:власник|власниця)\s+(?:зда[єе]|прода[єе])|no\s+(?:agency|broker|realtor|agent)|owner\s+direct|direct\s+from\s+(?:owner|landlord)|f(?:ă|a)r(?:ă|a)\s+(?:agen(?:ț|t)ie|intermediar\w*)|direct\s+(?:de\s+la\s+)?proprietar|makler\s*[- ]?siz|maklersiz|bez\s*makler(?:a|ov)?|bezmakler(?:a|ov)?|vositachi\s*[- ]?siz|vositachisiz|egasidan|uy\s+egasidan|делдалсыз|делдал\s*жоқ|иесінен|үй\s+иесінен)/iu;
|
|
54
54
|
|
|
55
55
|
// Cross-language "no commission" signals. Direct-owner language implies zero
|
|
56
56
|
// commission even without an explicit "no commission" phrase.
|
|
@@ -58,6 +58,8 @@ export interface HousingListingEnrichment {
|
|
|
58
58
|
district?: string | null;
|
|
59
59
|
quarter?: { number: number; suffix: string } | null;
|
|
60
60
|
metro?: string | null;
|
|
61
|
+
/** Every metro station named, primary (`metro`) first. */
|
|
62
|
+
metros?: readonly string[];
|
|
61
63
|
developmentArea?: string | null;
|
|
62
64
|
residenceComplex?: string | null;
|
|
63
65
|
address?: string | null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { deepFreeze } from './lexicon-core.js';
|
|
2
2
|
import { findAllCanonical, normalizeUnicode } from './normalization.js';
|
|
3
3
|
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
4
|
-
import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
|
|
4
|
+
import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingMetros, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
|
|
5
5
|
import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
|
|
6
6
|
import { TASHKENT_RESIDENTIAL_COMPLEXES, matchTashkentResidentialComplex } from './tashkent-residential-complexes.js';
|
|
7
7
|
import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
|
|
@@ -33,7 +33,7 @@ const APPLIANCE_PATTERNS = Object.freeze([
|
|
|
33
33
|
const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
|
|
34
34
|
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;
|
|
35
35
|
const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|(?<![\p{L}\p{N}_])(?:student(?:ka)?|talaba)(?:lar)?(?:ga)?\s+(?:qiz|yigit|o['’ʻʼ‘`]?g['’ʻʼ‘`]?il\s+bola|bola)(?:lar)?(?:ga)?(?![\p{L}\p{N}_])|(?<![\p{L}\p{N}_])(?:studentlar|talabalar)ga(?![\p{L}\p{N}_])|студент(?:ам|ы|ок|ов)?\s+(?:можно|для)|для\s+студент|students?\s+(?:only|welcome)|for\s+students|pentru\s+studen[țt]i|studen[țt]i(?:lor)?|студенттерге|студенттер\s+үшін|(?:oila|oyla)(?:ga|lar|li)?\s+yoki\s+\d{1,2}\s+ta\s+bola(?:lar)?(?:ga)?\s+(?:ijara(?:ga)?\s+)?(?:beril|topshiril))/iu;
|
|
36
|
-
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;
|
|
36
|
+
const NO_BROKER_RE = /(?:(?:makler|vositachi)(?:lar)?\s+bilan\s+ishla(?:n)?may\p{L}*|(?:makler|vositachi)(?:lar)?\s+(?:bezovta|bezota)\s+qilma\p{L}*|(?:с\s+)?(?:ри[еэ]лтор|маклер|агент(?:ств)?|посредник)\p{L}*\s+(?:просьба\s+)?не\s+(?:беспокоить|работа\p{L}*|звонить)|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;
|
|
37
37
|
const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
|
|
38
38
|
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;
|
|
39
39
|
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;
|
|
@@ -161,6 +161,16 @@ function cityMetro(text, country, city) {
|
|
|
161
161
|
return contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE)[0]?.canonical || null;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// Every station the listing names, primary first. A flat "between Nizomiy and
|
|
165
|
+
// Chilonzor" must keep both rather than silently dropping the second.
|
|
166
|
+
function listingMetros(text, country, city, primary) {
|
|
167
|
+
const names = [
|
|
168
|
+
...matchTashkentHousingMetros(text).map((station) => station.name),
|
|
169
|
+
...contextualCityMatches(text, country, city, 'metro', METRO_PREFIX_RE).map((match) => match.canonical),
|
|
170
|
+
];
|
|
171
|
+
return Object.freeze(unique(primary ? [primary, ...names] : names));
|
|
172
|
+
}
|
|
173
|
+
|
|
164
174
|
function cityDevelopmentArea(text, country, city) {
|
|
165
175
|
const normalizedCountry = String(country || '').toUpperCase();
|
|
166
176
|
if (!['KZ', 'UZ'].includes(normalizedCountry) || !city) return null;
|
|
@@ -310,6 +320,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
|
|
|
310
320
|
const quarter = matchTashkentHousingQuarter(text);
|
|
311
321
|
const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
|
|
312
322
|
const metro = matchTashkentHousingMetro(text)?.name || cityMetro(text, country, city) || null;
|
|
323
|
+
const metros = listingMetros(text, country, city, metro);
|
|
313
324
|
const developmentArea = cityDevelopmentArea(text, country, city);
|
|
314
325
|
const primaryResidentialText = withoutNearbyLocationReferences(text);
|
|
315
326
|
const parsedRc = specificResidentialComplex(primaryResidentialText)
|
|
@@ -386,6 +397,7 @@ export function parseHousingListingEnrichment(value, { country = '', city = '',
|
|
|
386
397
|
district: district || null,
|
|
387
398
|
quarter: quarter ? { number: quarter.number, suffix: quarter.suffix } : null,
|
|
388
399
|
metro: metro || null,
|
|
400
|
+
metros,
|
|
389
401
|
developmentArea,
|
|
390
402
|
residenceComplex: parsedRc || null,
|
|
391
403
|
address: address.address,
|
|
@@ -10,7 +10,7 @@ const bool = (text, positive, negative = null) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
const REFRIGERATOR_RE = /(?:холодильник\p{L}*|haladelnik|xolodilnik|muzlatgich|refrigerator|fridge)/iu;
|
|
13
|
-
const WASHING_MACHINE_RE = /(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|washing\s+machine|kir\s*
|
|
13
|
+
const WASHING_MACHINE_RE = /(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|washing\s+machine|kir\s*m[ao]shin\p{L}*|kirmoshina|kir\s*yuvish\s+mashin\p{L}*)/iu;
|
|
14
14
|
|
|
15
15
|
function listedUzbekAbsence(text, appliance) {
|
|
16
16
|
// Marketplace Uzbek frequently puts a single "yo'q" after a short,
|
|
@@ -173,7 +173,7 @@ export function parseHousingListingFields(value, { country = '', dealType = null
|
|
|
173
173
|
const washingMachine = applianceState(
|
|
174
174
|
text,
|
|
175
175
|
WASHING_MACHINE_RE,
|
|
176
|
-
/(?:без|нет)\s+(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*
|
|
176
|
+
/(?:без|нет)\s+(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*m[ao]shin\p{L}*|kirmoshina)|(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*m[ao]shin\p{L}*|kirmoshina)\s+(?:нет|yo['’ʻʼ`]?q|йўқ)|no\s+washing\s+machine/iu,
|
|
177
177
|
);
|
|
178
178
|
|
|
179
179
|
return deepFreeze({
|
package/src/housing-money.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { maskPhoneLikeSpans } from './contact.js';
|
|
11
11
|
import { DEPOSIT_TERMS, SELLER_TERMS } from './housing.js';
|
|
12
12
|
import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
|
|
13
|
+
import { countryCodeForCity } from './country-context.js';
|
|
13
14
|
import { extractTemporalCandidates } from './temporal.js';
|
|
14
15
|
import {
|
|
15
16
|
classifyHousingSingleMSpans,
|
|
@@ -92,7 +93,7 @@ const PRICE_AMOUNT_BEFORE_CURRENCY_RE = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMB
|
|
|
92
93
|
|
|
93
94
|
function moneyParsingContext(value = '') {
|
|
94
95
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
95
|
-
const country = canonicalCountryCode(value.country) ||
|
|
96
|
+
const country = canonicalCountryCode(value.country) || countryCodeForCity(value.city);
|
|
96
97
|
const currency = String(value.currency || value.fallbackCurrency || countryByCode(country)?.currency || '')
|
|
97
98
|
.trim()
|
|
98
99
|
.toUpperCase();
|
|
@@ -102,6 +103,14 @@ function moneyParsingContext(value = '') {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
const currency = String(value || '').trim().toUpperCase();
|
|
106
|
+
// A bare country code ("UZ") is not a currency. Treat it as the country so
|
|
107
|
+
// the fallback becomes its currency rather than the literal string "UZ".
|
|
108
|
+
const namedCountry = currency && !COUNTRIES.some((item) => item.currency === currency)
|
|
109
|
+
? canonicalCountryCode(value)
|
|
110
|
+
: null;
|
|
111
|
+
if (namedCountry) {
|
|
112
|
+
return { country: namedCountry, currency: countryByCode(namedCountry)?.currency || '', dealType: null };
|
|
113
|
+
}
|
|
105
114
|
const countryMatches = currency
|
|
106
115
|
? COUNTRIES.filter((item) => item.currency === currency)
|
|
107
116
|
: [];
|
|
@@ -406,6 +415,25 @@ export function parseHousingPricePerSqm(value, context = '') {
|
|
|
406
415
|
});
|
|
407
416
|
}
|
|
408
417
|
|
|
418
|
+
// An unlabelled amount far below any real local price is a USD price written
|
|
419
|
+
// without its sign: "Цена 500" is 500 USD, never 500 KZT or 500 UAH. The floors
|
|
420
|
+
// sit well under the cheapest genuine local rent/sale so a real tenge or
|
|
421
|
+
// hryvnia price is never reclassified. UZS has its own rule below.
|
|
422
|
+
const LOCAL_PRICE_FLOORS = Object.freeze({
|
|
423
|
+
KZT: Object.freeze({ sale: 1_000_000, daily: 1_000, rent: 10_000 }),
|
|
424
|
+
UAH: Object.freeze({ sale: 100_000, daily: null, rent: 1_000 }),
|
|
425
|
+
});
|
|
426
|
+
const DAILY_RENT_RE = /(?:посуточн|суточн|сутк|за\s+ночь|подобов|доба|tunlik|kunlik|sutkaga|тәулік|per\s+night|daily)/iu;
|
|
427
|
+
|
|
428
|
+
function lowLocalAmountCurrency(amount, currency, dealType, text) {
|
|
429
|
+
const floors = LOCAL_PRICE_FLOORS[currency];
|
|
430
|
+
if (!floors || amount == null) return currency;
|
|
431
|
+
const floor = dealType === 'sale' ? floors.sale
|
|
432
|
+
: dealType === 'shortRent' || DAILY_RENT_RE.test(text) ? floors.daily
|
|
433
|
+
: floors.rent;
|
|
434
|
+
return floor != null && amount > 0 && amount < floor ? 'USD' : currency;
|
|
435
|
+
}
|
|
436
|
+
|
|
409
437
|
export function parseHousingPrice(value, context = '') {
|
|
410
438
|
const { country, currency: fallbackCurrency, dealType } = moneyParsingContext(context);
|
|
411
439
|
const original = String(value || '');
|
|
@@ -427,9 +455,12 @@ export function parseHousingPrice(value, context = '') {
|
|
|
427
455
|
// regional formats below remain as deterministic fallbacks until each is
|
|
428
456
|
// represented by a richer candidate extractor.
|
|
429
457
|
if (preferredCandidate && preferredCandidate.confidence >= 0.65) {
|
|
458
|
+
const candidateCurrency = preferredCandidate.currency || fallbackCurrency || '';
|
|
430
459
|
const result = {
|
|
431
460
|
amount: preferredCandidate.amount,
|
|
432
|
-
currency: preferredCandidate.
|
|
461
|
+
currency: preferredCandidate.explicitCurrency
|
|
462
|
+
? candidateCurrency
|
|
463
|
+
: lowLocalAmountCurrency(preferredCandidate.amount, candidateCurrency, dealType, priceText),
|
|
433
464
|
approximate: preferredCandidate.approximate,
|
|
434
465
|
};
|
|
435
466
|
if (preferredCandidate.range) result.range = preferredCandidate.range;
|
|
@@ -606,6 +637,8 @@ export function parseHousingPrice(value, context = '') {
|
|
|
606
637
|
// turned "800 000" per person into an 800,000 USD rent.
|
|
607
638
|
const monthlyRent = dealType === 'longRent' && price >= 100_000;
|
|
608
639
|
currency = price >= 1_000_000 || monthlyRent || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
|
|
640
|
+
} else if (!explicit && currency === fallbackCurrency) {
|
|
641
|
+
currency = lowLocalAmountCurrency(price, currency, dealType, priceText);
|
|
609
642
|
}
|
|
610
643
|
|
|
611
644
|
return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(priceText) });
|
|
@@ -6,7 +6,7 @@ import { DEPOSIT_TERMS, SELLER_TERMS, UTILITY_TERMS } from './housing.js';
|
|
|
6
6
|
import { GENERIC_LANDMARK_TERMS } from './landmarks.js';
|
|
7
7
|
import { LOCATION_RELATIONS, parseHousingContext } from './housing-context.js';
|
|
8
8
|
import { isHousingCommercialAd, resolveHousingIntent } from './housing-intent.js';
|
|
9
|
-
import { countryCurrency, countryPhoneHint } from './country-context.js';
|
|
9
|
+
import { countryCodeForCity, countryCurrency, countryPhoneHint } from './country-context.js';
|
|
10
10
|
import { findTelegramContacts, maskPhoneLikeSpans, parsePhoneNumbers } from './contact.js';
|
|
11
11
|
import { parseHousingAddress } from './housing-address.js';
|
|
12
12
|
import { parseHousingListingFields } from './housing-listing-fields.js';
|
|
@@ -81,6 +81,14 @@ export function parseHousingRoomCount(value) {
|
|
|
81
81
|
if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// "1в2 комнатная" / "2 в 3-х комн.": a flat replanned from one room count
|
|
85
|
+
// into another is listed as the resulting layout.
|
|
86
|
+
const replanned = text.match(/(?:^|[^\p{L}\p{N}])\d{1,2}\s*(?:-\s*х\s*)?(?:к\.?\s*)?в\s*(\d{1,2})\s*(?:-\s*(?:х|ти|и|ух|[её]х)\s*)?(?:комн\p{L}*|к(?:\.|\b)|xona)/iu);
|
|
87
|
+
if (replanned) {
|
|
88
|
+
const rooms = toNumber(replanned[1]);
|
|
89
|
+
if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
const describedRooms = [...text.matchAll(/(?:^|[^\p{L}\p{N}_])(\d{1,2})\s*ta\s+(?:katta|kichkina)\s+xona(?=$|[^\p{L}\p{N}_])/giu)];
|
|
85
93
|
if (describedRooms.length > 1) {
|
|
86
94
|
const total = describedRooms.reduce((sum, match) => sum + Number(match[1]), 0);
|
|
@@ -414,7 +422,7 @@ export function parseHousingStructured(value, options = {}) {
|
|
|
414
422
|
const original = String(value ?? '');
|
|
415
423
|
const sourcePost = parseHousingSourcePost(original, { source: options.source });
|
|
416
424
|
const text = sourcePost.text;
|
|
417
|
-
const country = String(options.country || '').trim();
|
|
425
|
+
const country = String(options.country || '').trim() || countryCodeForCity(options.city);
|
|
418
426
|
const fallbackCurrency = options.fallbackCurrency || countryCurrency(country) || '';
|
|
419
427
|
const phoneCountry = options.phoneCountry || countryPhoneHint(country) || null;
|
|
420
428
|
const intent = resolveHousingIntent(text);
|
package/src/housing.js
CHANGED
|
@@ -120,8 +120,8 @@ export const CURRENCIES = Object.freeze([
|
|
|
120
120
|
|
|
121
121
|
export const SELLER_TERMS = Object.freeze({
|
|
122
122
|
owner: group('owner', {
|
|
123
|
-
ru: ['собственник', 'хозяин', 'от хозяина', 'без посредников', 'без посредника', 'без риелтора', 'без риэлтора', 'без маклера', 'без маклер', 'без агента'], en: ['owner', 'direct owner', 'no agent', 'no broker', 'no realtor'], uk: ['власник', 'власниця', 'від власника', 'без посередників'], ro: ['proprietar', 'direct proprietar', 'fără agenție', 'fara agentie', 'fără intermediari'],
|
|
124
|
-
uzLatn: ['egasi', 'uy egasi', 'mulkdor', 'maklersiz', 'vositachisiz'], uzCyrl: ['эгаси', 'уй эгаси', 'мулкдор', 'маклерсиз', 'воситачисиз', 'без маклер'], kk: ['иесі', 'үй иесі', 'меншік иесі', 'делдалсыз'],
|
|
123
|
+
ru: ['собственник', 'хозяин', 'от хозяина', 'без посредников', 'без посредника', 'без риелтора', 'без риэлтора', 'без маклера', 'без маклер', 'без агента', 'с риелторами не работаю', 'с риэлторами не работаю', 'с маклерами не работаю', 'с агентствами не работаю', 'риелторам не беспокоить', 'риэлторам не беспокоить', 'агентствам не беспокоить'], en: ['owner', 'direct owner', 'no agent', 'no broker', 'no realtor'], uk: ['власник', 'власниця', 'від власника', 'без посередників'], ro: ['proprietar', 'direct proprietar', 'fără agenție', 'fara agentie', 'fără intermediari'],
|
|
124
|
+
uzLatn: ['egasi', 'uy egasi', 'mulkdor', 'maklersiz', 'vositachisiz', 'bezmakler', 'bez makler', 'maklerlar bilan ishlanmaydi', 'maklerlar bilan ishlamaymiz', 'makler bilan ishlanmaydi', 'maklerlar bezovta qilmasin'], uzCyrl: ['эгаси', 'уй эгаси', 'мулкдор', 'маклерсиз', 'воситачисиз', 'без маклер', 'маклерлар билан ишланмайди', 'маклерлар безовта қилмасин'], kk: ['иесі', 'үй иесі', 'меншік иесі', 'делдалсыз'],
|
|
125
125
|
}),
|
|
126
126
|
agency: group('agency', {
|
|
127
127
|
ru: ['агентство', 'агент', 'риелтор', 'риэлтор', 'маклер', 'посредник'], en: ['agency', 'agent', 'realtor', 'broker'], uk: ['агентство', 'агент', 'рієлтор', 'риелтор', 'посередник'], ro: ['agenție', 'agentie', 'agent imobiliar', 'agenție imobiliară', 'agentie imobiliara', 'broker'],
|
package/src/normalization.js
CHANGED
|
@@ -310,6 +310,8 @@ function matcherFor(entries, { transliteration = true } = {}) {
|
|
|
310
310
|
* Return every canonical match with original-text offsets. Colliding aliases are
|
|
311
311
|
* deliberately returned as multiple matches instead of silently choosing one.
|
|
312
312
|
*/
|
|
313
|
+
const UZBEK_APOSTROPHE_CONTINUATION_RE = /^[og]['’ʻʼ‘`]\p{L}/iu;
|
|
314
|
+
|
|
313
315
|
export function findAllCanonical(value, entries, { transliteration = true } = {}) {
|
|
314
316
|
const text = normalizeUnicode(value ?? '');
|
|
315
317
|
if (!text || !entries?.length) return [];
|
|
@@ -323,6 +325,10 @@ export function findAllCanonical(value, entries, { transliteration = true } = {}
|
|
|
323
325
|
const alias = match[0];
|
|
324
326
|
const start = match.index ?? 0;
|
|
325
327
|
const end = start + alias.length;
|
|
328
|
+
// Uzbek Latin writes oʻ/gʻ with an apostrophe inside the word, so an alias
|
|
329
|
+
// that stops on o/g right before one ("bog" in "bog'laning", "contact us")
|
|
330
|
+
// has matched only the front of a longer word.
|
|
331
|
+
if (UZBEK_APOSTROPHE_CONTINUATION_RE.test(text.slice(end - 1, end + 2))) continue;
|
|
326
332
|
const keys = normalizedAliasKeys(alias, { transliteration });
|
|
327
333
|
const candidates = [];
|
|
328
334
|
for (const key of keys) {
|
|
@@ -46,4 +46,5 @@ export function hasExplicitTashkentDistrict(value: unknown, canonical: string):
|
|
|
46
46
|
export function matchTashkentHousingDistrict(value: unknown): TashkentDistrictEntry | null;
|
|
47
47
|
export function matchTashkentHousingQuarter(value: unknown): Readonly<{ district: string; number: number; suffix: string }> | null;
|
|
48
48
|
export function matchTashkentHousingMetro(value: unknown): TashkentMetroEntry | null;
|
|
49
|
+
export function matchTashkentHousingMetros(value: unknown): ReadonlyArray<TashkentMetroEntry>;
|
|
49
50
|
export function matchTashkentHousingTransit(value: unknown): TashkentHousingLocationEntry | null;
|
|
@@ -213,7 +213,7 @@ export function matchTashkentNumberedArea(value, canonical) {
|
|
|
213
213
|
if (!text) return null;
|
|
214
214
|
const alternatives = normalizedAlternatives(aliases);
|
|
215
215
|
let match = text.match(new RegExp(
|
|
216
|
-
`(?:^|\\s)(?:${alternatives})(?:\\s+${NUMBERED_CONTEXT})?\\s+(\\d{1,2})(?:\\s*([adад]))?(?:\\s+${NUMBERED_SUFFIX_CONTEXT})*(?:\\s|$)`,
|
|
216
|
+
`(?:^|\\s)(?:${alternatives})(?:\\s+${NUMBERED_CONTEXT})?\\s+(\\d{1,2})(?:\\s*([adад]))?(?:\\s*(?:кв|kv)(?=\\s|$))?(?:\\s+${NUMBERED_SUFFIX_CONTEXT})*(?:\\s|$)`,
|
|
217
217
|
'iu',
|
|
218
218
|
));
|
|
219
219
|
|
|
@@ -298,42 +298,75 @@ const METRO_NUMBERED_AREA = Object.freeze({
|
|
|
298
298
|
Yangihayot: 'Yangihayot',
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
// Position of the first mention of `station` that really names the station,
|
|
302
|
+
// or -1. An explicit marker must win even if an earlier bare occurrence shares
|
|
303
|
+
// a name with a district: "Sergeli tumani, metro Sergeli" still refers to the
|
|
304
|
+
// station at the second occurrence.
|
|
305
|
+
function stationMentionIndex(text, station) {
|
|
306
|
+
const flags = [...new Set(`${station.re.flags.replace(/g/gu, '')}g`)].join('');
|
|
307
|
+
const matches = [...text.matchAll(new RegExp(station.re.source, flags))];
|
|
308
|
+
if (!matches.length) return -1;
|
|
309
|
+
const explicit = matches.find((match) => hasExplicitMetroContext(text, match));
|
|
310
|
+
if (explicit) return explicit.index ?? 0;
|
|
311
|
+
if (hasExplicitTashkentDistrict(text, station.name)) return -1;
|
|
312
|
+
// "Toshkent" is both a metro station and the city's own name, so a bare
|
|
313
|
+
// mention ("Toshkent shahri") is not evidence of the station the way a bare
|
|
314
|
+
// mention of any other station name would be. Require an explicit metro
|
|
315
|
+
// context for this one station specifically.
|
|
316
|
+
if (station.name === 'Toshkent') return -1;
|
|
317
|
+
if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) return -1;
|
|
318
|
+
const areaCanonical = METRO_NUMBERED_AREA[station.name];
|
|
319
|
+
if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) return -1;
|
|
320
|
+
for (const match of matches) {
|
|
321
|
+
if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
|
|
322
|
+
return match.index ?? 0;
|
|
323
|
+
}
|
|
324
|
+
return -1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function extraAliasMentionIndex(text, aliases) {
|
|
328
|
+
const match = text.match(aliasesToRegex(aliases));
|
|
329
|
+
if (!match) return -1;
|
|
330
|
+
if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) return -1;
|
|
331
|
+
return match.index ?? 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
301
334
|
/** Resolve listing typos/transliterations while respecting explicit non-metro geography. */
|
|
302
335
|
export function matchTashkentHousingMetro(value) {
|
|
303
336
|
const text = String(value ?? '');
|
|
304
337
|
if (!text) return null;
|
|
305
338
|
for (const station of TASHKENT_METRO) {
|
|
306
|
-
|
|
307
|
-
const matches = [...text.matchAll(new RegExp(station.re.source, flags))];
|
|
308
|
-
if (!matches.length) continue;
|
|
309
|
-
// An explicit marker must win even if an earlier bare occurrence shares a
|
|
310
|
-
// name with a district. For example, "Sergeli tumani, metro Sergeli"
|
|
311
|
-
// still refers to the station at the second occurrence.
|
|
312
|
-
if (matches.some((match) => hasExplicitMetroContext(text, match))) return station;
|
|
313
|
-
const sameNamedDistrict = hasExplicitTashkentDistrict(text, station.name);
|
|
314
|
-
for (const match of matches) {
|
|
315
|
-
if (sameNamedDistrict) continue;
|
|
316
|
-
// "Toshkent" is both a metro station and the city's own name, so a bare
|
|
317
|
-
// mention ("Toshkent shahri") is not evidence of the station the way a
|
|
318
|
-
// bare mention of any other station name would be. Require an explicit
|
|
319
|
-
// metro context for this one station specifically.
|
|
320
|
-
if (station.name === 'Toshkent') continue;
|
|
321
|
-
if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
|
|
322
|
-
const areaCanonical = METRO_NUMBERED_AREA[station.name];
|
|
323
|
-
if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
|
|
324
|
-
if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match) || hasExplicitLandmarkContext(text, match)) continue;
|
|
325
|
-
return station;
|
|
326
|
-
}
|
|
339
|
+
if (stationMentionIndex(text, station) !== -1) return station;
|
|
327
340
|
}
|
|
328
341
|
for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
|
|
329
|
-
|
|
330
|
-
if (!match) continue;
|
|
331
|
-
if (hasExplicitDistrictContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) continue;
|
|
342
|
+
if (extraAliasMentionIndex(text, aliases) === -1) continue;
|
|
332
343
|
return TASHKENT_METRO.find((station) => station.name === canonical) || null;
|
|
333
344
|
}
|
|
334
345
|
return null;
|
|
335
346
|
}
|
|
336
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Every Tashkent metro station a listing names, in text order. Listings often
|
|
350
|
+
* sit between stations ("Nizomiy & Chilonzor metrosiga yaqin"); the singular
|
|
351
|
+
* matcher above keeps its historical first-station contract.
|
|
352
|
+
*/
|
|
353
|
+
export function matchTashkentHousingMetros(value) {
|
|
354
|
+
const text = String(value ?? '');
|
|
355
|
+
if (!text) return Object.freeze([]);
|
|
356
|
+
const found = new Map();
|
|
357
|
+
for (const station of TASHKENT_METRO) {
|
|
358
|
+
const index = stationMentionIndex(text, station);
|
|
359
|
+
if (index !== -1) found.set(station.name, { station, index });
|
|
360
|
+
}
|
|
361
|
+
for (const [canonical, aliases] of Object.entries(EXTRA_METRO_ALIASES)) {
|
|
362
|
+
if (found.has(canonical)) continue;
|
|
363
|
+
const index = extraAliasMentionIndex(text, aliases);
|
|
364
|
+
const station = TASHKENT_METRO.find((entry) => entry.name === canonical);
|
|
365
|
+
if (index !== -1 && station) found.set(canonical, { station, index });
|
|
366
|
+
}
|
|
367
|
+
return Object.freeze([...found.values()].sort((a, b) => a.index - b.index).map((item) => item.station));
|
|
368
|
+
}
|
|
369
|
+
|
|
337
370
|
const TASHKENT_HOUSING_TRANSIT = Object.freeze([
|
|
338
371
|
locationEntry('Tashkent North Railway Station', 'railway', [
|
|
339
372
|
'Ташкент Северный вокзал',
|