@whiteslove/parsing-lexicon 0.10.0 → 0.10.1

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.0",
3
+ "version": "0.10.1",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/geo.js CHANGED
@@ -19,7 +19,7 @@ export const TASHKENT_DISTRICTS = Object.freeze([
19
19
  entity('Shaykhantahur', { uzLatn: ['Shayxontohur', 'Shayxontohur tumani', 'Shaykhantahur'], uzCyrl: ['Шайхонтоҳур', 'Шайхонтоҳур тумани', 'Шайхонтохур'], ru: ['Шайхантахур', 'Шайхантахурский район'], en: ['Shaykhantahur', 'Shayxontohur'] }),
20
20
  entity('Yunusabad', { uzLatn: ['Yunusobod', 'Yunusobod tumani', 'Yunusabad'], uzCyrl: ['Юнусобод', 'Юнусобод тумани'], ru: ['Юнусабад', 'Юнусабадский район'], en: ['Yunusabad', 'Yunusobod'] }),
21
21
  entity('Yakkasaray', { uzLatn: ['Yakkasaroy', 'Yakkasaroy tumani', 'Yakkasaray'], uzCyrl: ['Яккасарой', 'Яккасарой тумани'], ru: ['Яккасарай', 'Яккасарайский район'], en: ['Yakkasaray', 'Yakkasaroy'] }),
22
- entity('Yangihayot', { uzLatn: ['Yangihayot', 'Yangihayot tumani'], uzCyrl: ['Янгиҳаёт', 'Янгиҳаёт тумани', 'Янгихаёт'], ru: ['Янгихаётский район', 'Янгихаёт'], en: ['Yangihayot'] }),
22
+ entity('Yangihayot', { uzLatn: ['Yangihayot', 'Yangihayot tumani'], uzCyrl: ['Янгиҳаёт', 'Янгиҳаёт тумани', 'Янгихаёт'], ru: ['Янгихаётский район', 'Янгихаёт', 'Янгихаетский район', 'Янгихаетский', 'Янгихает'], en: ['Yangihayot'] }),
23
23
  entity('Yashnobod', { uzLatn: ['Yashnobod', 'Yashnobod tumani', 'Yashnabod', 'Yashnabod tumani', 'Yashnobot', 'Yashnobot tumani'], uzCyrl: ['Яшнобод', 'Яшнобод тумани'], ru: ['Яшнабад', 'Яшнабадский район'], en: ['Yashnobod', 'Yashnabad'] }),
24
24
  ]);
25
25
 
@@ -32,7 +32,7 @@ const APPLIANCE_PATTERNS = Object.freeze([
32
32
 
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
- const STUDENT_RE = /(?:studentlar\s+uchun|talabalar\s+uchun|студент(?:ам|ы|ок|ов)?\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;
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
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;
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;
@@ -45,6 +45,10 @@ const FAMILY_EXCLUSION_RE = /(?:oila|oyla|семь\p{L}*|family|famil(?:ie|ia)|c
45
45
  const ROOM_SHARE_RE = /(?:sherik(?:ka|lik|likga)?|шерик(?:ка|лик)?|roommate|flatmate|подселени|койко[-\s]?мест|место\s+в\s+(?:комнат|квартир)|birga\s+yashash(?:ga)?|kvartira(?:ga|da)?[^\r\n.!?]{0,36}(?:\d+|bitta|1)\s*(?:ta\s*)?(?:qiz|ayol)[^\r\n.!?]{0,20}(?:ijarachi\s*)?(?:kerak|kere)|coleg\s+de\s+(?:apartament|camer[ăa])|bed\s+space|бөлмелес(?:\s+керек)?|көрші\s+керек)/iu;
46
46
  const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
47
47
  const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
48
+ // Shared-flat posts price each tenant with the ablative "-dan" ("800 000
49
+ // so'mdan" = "800 000 so'm from each") once they ask for several people.
50
+ const PER_PERSON_SUFFIX_PRICE_RE = /(\d{1,3}(?:[  ]\d{3})+|\d+)\s*(ming|минг|million|mln|млн)?\s*(?:so['’ʻʼ‘`]?m|сўм|сум)(?:dan|дан)(?![\p{L}\p{N}_])/iu;
51
+ const SEVERAL_OCCUPANTS_RE = /(?<![\p{L}\p{N}_])([2-9])\s*ta\s+(?:qiz|yigit|bola|kishi|odam|talaba|student)\p{L}*\s+(?:kerak|kere|olinadi)/iu;
48
52
  const WALK_MINUTES_RE = /(?:yayov|piyoda|пешком)\s*(\d{1,2})\s*(?:daqiqa|min(?:ute)?s?|минут)/iu;
49
53
  const TRANSIT_ROUTES_RE = /(?:aftobuslar|avtobuslar|автобуслар|автобусы)[^\r\n\d]{0,24}((?:\d{1,4}[\s,;/]*){1,10})/iu;
50
54
  const NEARBY_RELATION_TAIL_RE = /(?<!\p{L})(?:рядом\s+(?:с|со)|недалеко\s+от|возле|около|напротив|навпроти|ориентир\s*[:—–-]?|ор[-–—]?р\.?\s*[:—–-]?|near(?:by)?|close\s+to|next\s+to|opposite|behind|in\s+front\s+of|yaqin(?:ida)?|lângă|aproape\s+de|în\s+apropiere\s+de|vizavi\s+de|în\s+spatele|în\s+fața)(?!\p{L})[^.!?\r\n;]*/giu;
@@ -194,7 +198,9 @@ export function parseHousingAudience(value) {
194
198
  const students = STUDENT_RE.test(text);
195
199
  if (family && students) return deepFreeze({ primary: 'family', alternatives: ['family', 'students'] });
196
200
  if (family && women) return deepFreeze({ primary: 'family', alternatives: ['family', 'women'] });
197
- if (students) return deepFreeze({ primary: 'students', alternatives: ['students'] });
201
+ // "Student qizlarga" narrows students to girls: the gender is the binding
202
+ // restriction, and studentTarget still reports the student part.
203
+ if (students && !women && !men) return deepFreeze({ primary: 'students', alternatives: ['students'] });
198
204
  if (family) return deepFreeze({ primary: 'family', alternatives: ['family'] });
199
205
  if (women) return deepFreeze({ primary: 'women', alternatives: ['women'] });
200
206
  if (men) return deepFreeze({ primary: 'men', alternatives: ['men'] });
@@ -222,14 +228,15 @@ export function parseHousingCommission(value) {
222
228
 
223
229
  export function parseHousingPerPersonPrice(value, { country = '' } = {}) {
224
230
  const text = normalizeUnicode(value ?? '');
225
- const match = text.match(PER_PERSON_PRICE_RE);
231
+ const match = text.match(PER_PERSON_PRICE_RE)
232
+ || (SEVERAL_OCCUPANTS_RE.test(text) ? text.match(PER_PERSON_SUFFIX_PRICE_RE) : null);
226
233
  if (!match) return null;
227
234
  let amount = Number(String(match[1]).replace(/\s+/g, '').replace(',', '.'));
228
235
  if (!Number.isFinite(amount) || amount <= 0) return null;
229
236
  const scale = String(match[2] || '').toLowerCase();
230
237
  if (scale === 'ming' || scale === 'минг') amount *= 1000;
231
238
  else if (scale === 'million' || scale === 'mln' || scale === 'млн') amount *= 1_000_000;
232
- const currency = String(country || '').toUpperCase() === 'UZ' || /(?:ming|минг)/iu.test(match[0]) ? 'UZS' : null;
239
+ const currency = String(country || '').toUpperCase() === 'UZ' || /(?:ming|минг|so['’ʻʼ‘`]?m|сўм|сум)/iu.test(match[0]) ? 'UZS' : null;
233
240
  const approximate = /около|примерно|~|≈/iu.test(text);
234
241
  return deepFreeze({ amount, currency, approximate, scope: 'person' });
235
242
  }
@@ -602,7 +602,10 @@ export function parseHousingPrice(value, context = '') {
602
602
  const hasExplicitUzbekThousandScale = country === 'UZ' && /\b(?:ming|минг)\b/iu.test(priceText);
603
603
  if (!explicit && !hasExplicitUzbekThousandScale && fallbackCurrency === 'UZS' && price != null) {
604
604
  const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(priceText);
605
- currency = price >= 1_000_000 || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
605
+ // A monthly rent of 100,000+ is only plausible in so'm; the USD reading
606
+ // turned "800 000" per person into an 800,000 USD rent.
607
+ const monthlyRent = dealType === 'longRent' && price >= 100_000;
608
+ currency = price >= 1_000_000 || monthlyRent || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
606
609
  }
607
610
 
608
611
  return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(priceText) });
@@ -87,7 +87,9 @@ export function parseHousingRoomCount(value) {
87
87
  if (total >= 1 && total <= 20) return total;
88
88
  }
89
89
  for (const [re, rooms] of NUMBER_WORDS) if (re.test(text)) return rooms;
90
- const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:ta\s*)?(?:(?:-\s*)?комнат\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li|si|lari)?|(?:-\s*)?хона(?:лик|ли|си|лари)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
90
+ // "5-х комн.кв" / "3-ех комнатная": a Russian case ending may sit between
91
+ // the number and the room word, which may itself be abbreviated.
92
+ const numeric = text.match(/(?:^|[^\p{L}\p{N}])(\d{1,2})\s*(?:-\s*(?:х|ти|и|ух|[её]х)\s*)?(?:ta\s*)?(?:(?:-\s*)?комн(?:ат)?\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li|si|lari)?|(?:-\s*)?хона(?:лик|ли|си|лари)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
91
93
  if (numeric) {
92
94
  const rooms = toNumber(numeric[1]);
93
95
  if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
@@ -127,7 +129,8 @@ export function parseHousingFloor(value) {
127
129
  }
128
130
  }
129
131
 
130
- const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
132
+ // "3-й этаж" / "5-м этаже" carry a Russian ordinal ending like Uzbek "-chi".
133
+ const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи|й|ий|ый|ой|м|ом|ем)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
131
134
  let floor = toNumber(beforeMarker?.[1]);
132
135
  let totalFloors = null;
133
136
 
@@ -140,7 +143,7 @@ export function parseHousingFloor(value) {
140
143
  if (floor == null && FIRST_FLOOR_WORD_RE.test(text)) floor = 1;
141
144
 
142
145
  if (totalFloors == null) {
143
- const total = text.match(/(?:дом\s*)?(\d{1,3})\s*(?:[- ]?этаж(?:н\p{L}*|лик)|поверхов\p{L}*|storey|story|floors?\s+total|qavatli|қабатты|каватли|қаватли)/iu);
146
+ const total = text.match(/(?:дом\s*)?(\d{1,3})\s*(?:-\s*(?:ти|х|и|ми)\s*)?(?:[- ]?этаж(?:н\p{L}*|лик)|поверхов\p{L}*|storey|story|floors?\s+total|qavatli|қабатты|каватли|қаватли)/iu);
144
147
  totalFloors = toNumber(total?.[1]);
145
148
  }
146
149
 
@@ -167,7 +170,7 @@ const AREA_UNIT_RE = String.raw`(?:м²|м2|m²|m2|sqm|sq\.?\s*m|mp|кв\.?\s*м
167
170
  const AREA_UNIT_AFTER_LABEL_RE = String.raw`(?:${AREA_UNIT_RE}|[mм])`;
168
171
 
169
172
  function areaAfterLabel(text, labelRe) {
170
- const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_AFTER_LABEL_RE}(?=$|[^\\p{L}\\p{N}_])`, 'iu');
173
+ const re = new RegExp(`${labelRe.source}\\s*[:=-]?\\s*(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*-?\\s*${AREA_UNIT_AFTER_LABEL_RE}(?=$|[^\\p{L}\\p{N}_])`, 'iu');
171
174
  return toNumber(text.match(re)?.[1]);
172
175
  }
173
176
 
@@ -5,7 +5,7 @@ const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, alias
5
5
  export const CURRENCY_TERMS = Object.freeze([
6
6
  group('USD', { ru: ['$', 'usd', 'доллар', 'доллара', 'долларов', 'бакс', 'у.е.', 'у е'], en: ['$', 'usd', 'dollar', 'dollars', 'us dollar'], uk: ['$', 'usd', 'долар', 'долари', 'доларів'], ro: ['$', 'usd', 'dolar', 'dolari'], uzLatn: ['$', 'usd', 'dollar'], uzCyrl: ['$', 'доллар'], kk: ['$', 'usd', 'доллар'] }),
7
7
  group('EUR', { ru: ['€', 'eur', 'евро'], en: ['€', 'eur', 'euro', 'euros'], uk: ['€', 'eur', 'євро'], ro: ['€', 'eur', 'euro'], uzLatn: ['€', 'eur', 'yevro'], uzCyrl: ['€', 'евро'], kk: ['€', 'eur', 'еуро'] }),
8
- group('UZS', { ru: ['сум', 'сумов', 'узс'], en: ['uzs', 'uzbek som'], uk: ['uzs', 'сум'], ro: ['uzs'], uzLatn: ["so'm", 'so‘m', 'soʻm', 'sum', 'uzs'], uzCyrl: ['сўм', 'сум'], kk: ['uzs'] }),
8
+ group('UZS', { ru: ['сум', 'сумов', 'узс'], en: ['uzs', 'uzbek som'], uk: ['uzs', 'сум'], ro: ['uzs'], uzLatn: ["so'm", 'so‘m', 'soʻm', 'sum', 'uzs', "so'mdan", 'so‘mdan', 'soʻmdan', 'somdan', 'sumdan', "so'mgacha", 'so‘mgacha', 'soʻmgacha'], uzCyrl: ['сўм', 'сум', 'сўмдан', 'сумдан', 'сўмгача'], kk: ['uzs'] }),
9
9
  group('KZT', { ru: ['₸', 'тенге', 'тг', 'kzt'], en: ['₸', 'kzt', 'tenge'], uk: ['₸', 'тенге', 'kzt'], ro: ['₸', 'kzt', 'tenge'], uzLatn: ['₸', 'kzt', 'tenge'], uzCyrl: ['₸', 'kzt', 'тенге'], kk: ['₸', 'теңге', 'тенге', 'тг', 'kzt'] }),
10
10
  group('UAH', { ru: ['грн', 'гривна', 'гривны', 'гривен', '₴', 'uah'], en: ['uah', 'hryvnia', 'hryvnias', '₴'], uk: ['грн', 'гривня', 'гривні', 'гривень', '₴', 'uah'], ro: ['uah', 'hrivne', 'grivne'], uzLatn: ['uah'], uzCyrl: ['uah'], kk: ['uah'] }),
11
11
  group('RUB', { ru: ['₽', 'руб', 'руб.', 'рубль', 'рубля', 'рублей', 'rub', 'rur'], en: ['₽', 'rub', 'rur', 'ruble', 'rubles'], uk: ['₽', 'руб', 'рублів', 'rub'], ro: ['₽', 'rub', 'ruble'], uzLatn: ['₽', 'rub', 'rubl'], uzCyrl: ['₽', 'рубль'], kk: ['₽', 'rub', 'рубль'] }),
@@ -167,7 +167,7 @@ export const TASHKENT_NUMBERED_AREA_ALIASES = Object.freeze({
167
167
  Kuylyuk: Object.freeze(['куйлюк', 'куйлик', 'kuylyuk', 'kuyliq', 'qoyliq', 'qo yliq']),
168
168
  Sergeli: Object.freeze(['сергели', 'sergeli', 'sergile', 'sergele']),
169
169
  Yunusabad: Object.freeze(['юнусабад', 'yunusabad', 'yunusobod']),
170
- Yangihayot: Object.freeze(['янгихаёт', 'янгихаят', 'yangihayot']),
170
+ Yangihayot: Object.freeze(['янгихаёт', 'янгихает', 'янгихаят', 'yangihayot']),
171
171
  });
172
172
 
173
173
  const REVERSE_NUMBERED_ALIASES = Object.freeze({
@@ -241,15 +241,24 @@ export function hasExplicitTashkentDistrict(value, canonical) {
241
241
  export function matchTashkentHousingDistrict(value) {
242
242
  const text = String(value ?? '');
243
243
  if (!text) return null;
244
+ let explicit = null;
245
+ let implicit = null;
244
246
  for (const district of TASHKENT_HOUSING_DISTRICTS) {
245
247
  const match = text.match(district.re);
246
248
  if (!match) continue;
247
- if (hasExplicitDistrictContext(text, match)) return district;
249
+ // The earliest explicitly marked district wins. In "Янгихаетский район
250
+ // Сергели-1" the "район" also precedes Сергели, but it belongs to the
251
+ // adjective before it; Sergeli-1 is only the quarter name.
252
+ if (hasExplicitDistrictContext(text, match)) {
253
+ if (!explicit || match.index < explicit.index) explicit = { district, index: match.index };
254
+ continue;
255
+ }
256
+ if (implicit) continue;
248
257
  if (hasExplicitMetroContext(text, match) || hasExplicitAreaContext(text, match) || hasExplicitMahallaContext(text, match)) continue;
249
258
  if (matchTashkentNumberedArea(text, district.name)) continue;
250
- return district;
259
+ implicit = district;
251
260
  }
252
- return null;
261
+ return explicit?.district || implicit;
253
262
  }
254
263
 
255
264
  /**