@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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;
@@ -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
+ }
@@ -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;
@@ -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 = /(?:без\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;
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*moshina|kirmoshina|kir\s*yuvish\s+mashin\p{L}*)/iu;
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*moshina|kirmoshina)|(?:кир\s*машин\p{L}*|кирмошин\p{L}*|стиральн\p{L}*\s+машин\p{L}*|kir\s*moshina|kirmoshina)\s+(?:нет|yo['’ʻʼ`]?q|йўқ)|no\s+washing\s+machine/iu,
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({
@@ -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.currency || fallbackCurrency || '',
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'],
@@ -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
- 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) 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
- const match = text.match(aliasesToRegex(aliases));
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
  'Ташкент Северный вокзал',