@whiteslove/parsing-lexicon 0.9.1 → 0.9.4

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.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  import { LOCATION_DICTIONARIES } from './locations-runtime.js';
2
- import { LOCATION_LIST_KEYS } from './location-merge.js';
2
+ import { isMapDataEntry, LOCATION_LIST_KEYS } from './location-merge.js';
3
3
  import { CITIES_BY_COUNTRY, canonicalCity } from './geography.js';
4
4
  import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
5
5
  import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_SEARCH_CLUSTERS } from './kz-location-extensions.js';
@@ -227,12 +227,24 @@ function publicMatch(candidate) {
227
227
  return Object.freeze(result);
228
228
  }
229
229
 
230
- function findEntryMatches(text, cityName, data) {
230
+ function mapDataMatch(value, normalizedValue, item) {
231
+ const paddedValue = ` ${normalizedValue} `;
232
+ for (const alias of item.aliases || []) {
233
+ const normalizedAlias = normalizeForMatch(alias);
234
+ const index = paddedValue.indexOf(` ${normalizedAlias} `);
235
+ if (normalizedAlias && index >= 0) return { 0: normalizedAlias, index };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function findEntryMatches(text, cityName, data, { includeMapData = false } = {}) {
231
241
  const value = String(text || '');
242
+ const normalizedValue = normalizeForMatch(value);
232
243
  const raw = [];
233
244
  for (const key of LOCATION_LIST_KEYS) {
234
245
  for (const item of data?.[key] || []) {
235
- const match = value.match(item?.re)
246
+ if (isMapDataEntry(item) && !includeMapData) continue;
247
+ const match = (isMapDataEntry(item) ? mapDataMatch(value, normalizedValue, item) : value.match(item?.re))
236
248
  || (key === 'residentialComplexes' ? markedResidentialMatch(value, item) : null);
237
249
  if (!match) continue;
238
250
  const { start, end } = semanticBounds(value, match);
@@ -319,7 +331,7 @@ export function matchCentralAsiaLocationEntities(text, countryCode, preferredCit
319
331
  const scopedCity = preferred && country[preferred] ? preferred : explicit && country[explicit] ? explicit : null;
320
332
 
321
333
  if (scopedCity) {
322
- const matches = findEntryMatches(text, scopedCity, country[scopedCity]);
334
+ const matches = findEntryMatches(text, scopedCity, country[scopedCity], { includeMapData: true });
323
335
  const clusters = clusterMatches(matches, countryCode);
324
336
  return Object.freeze({ city: scopedCity, matches: Object.freeze(matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
325
337
  }
@@ -2,7 +2,7 @@
2
2
  // residential housing feeds. Kept conservative so nearby amenities (for
3
3
  // example "магазин рядом") do not classify an otherwise valid home as commercial.
4
4
  const COMMERCIAL_HOUSING_RE =
5
- /(офис[ _|,/.]|под ?офис|офисн|\boffice\b|\bofis\b|кеңсе|коммерческ|commercial|бизнес[ -]?центр|не\s?жил(?:ое|ой|ый|ых|ым|ого)|помещени[ея]|торгов(?:ое|ая) ?площад|торгов(?:ое|ое помещ)|warehouse|склад(?!н|ыв)|производствен(?:ное|ых) ?помещ|spatiu comercial|(?:închiriez|inchiriez|vând|vand)\s+birou|birou\s+de\s+(?:închiriat|inchiriat|vânzare|vanzare)|\d+\s*sot(?:ix|ka)|\d+\s*сот(?:ок|ка|ки|ых)|yer\s*maydoni|bosh\s*yer|уч[аа]сток\s*земл|servis\s*uchun|kassaprav|avtomoyka|автомойк|car\s?wash|шиномонтаж|салон\s*красот|zallik\s*saloni|beauty\s*salon|beauty[\s-]?кабинет|космет(?:ическ|олог)[а-яё]*\s*кабинет|массажн[а-яё]*\s*кабинет|маникюрн[а-яё]*\s*(?:кабинет|студи)|nail\s*(?:bar|studio|salon)|аренда\s+(?:beauty\s+)?кабинет|парикмахерск|барбершоп|barbershop|аренда\s+рабоч(?:его|ее)\s*мест|рабоч(?:ее|его)\s*мест[оае]\s+(?:мастер|для\s+мастер|под\s+)|оренд[аи]\s+робоч(?:ого|е)\s*місц|аренда\s+гараж|гараж[ае]?\s+(?:аренд|сда[её]тся|ijara)|\bgaraj\b|\bgarage\b|o[\u2018\u2019\u02bb\u02bc'`ʻʼ]?quv\s*xona|o[\u2018\u2019\u02bb\u02bc'`ʻʼ]?quv\s*markaz|учебн(?:ый|ое|ая|ого)\s*(?:класс|помещ|кабинет|центр)|под\s+(?:бар|каф[её]|ресторан|магазин|салон|склад|бизнес|спортпит|спорт\s*пит|аптек|пекарн|пункт|шоурум|showroom|офис)|шоурум|showroom|спортпит|sportpit|\bsklad(?:lar)?\b|\bombor(?:xona)?\b|(?:avto\s*)?ser?vis\s+(?:arenda|ijara|beriladi)|авто\s?сервис\s+(?:аренд|сда[её]тся)|детейлинг|detailing|^\s*парковк|avtoturargoh|sot(?:ish|uv)\w*\s*joy|savdo\s*(?:uchun|joy)|(?:ijara\w*|arenda\w*|sot(?:ish|uv)\w*)\s+do[\u2018\u2019'`ʻʼ]?kon|do[\u2018\u2019'`ʻʼ]?kon\s+(?:ijara\w*|arenda\w*|sot(?:ish|uv)\w*)|(?:аренд|сда[её]т|сдам|прода|продаж)[а-яё]*\s+(?:(?:в|под|аренду|готов[а-яё]*|действующ[а-яё]*|срочно|отдельн[а-яё]*|стоящ[а-яё]*|цел[а-яё]*)\s+){0,3}(?:здани|каф[её])|бутик|[|,/]\s*(?:салон|бутик|аптек)|arenda\w*\s+joy|ijara\w*\s+joy|готов[а-яё]*\s+бизнес|tayyor\s+biznes|бизнес\s+(?:ресторан|каф[её]|магазин)|(?:сда[её]тся|аренда|прода[её]тся)\s+(?:готов[а-яё]*\s+)?(?:ресторан|каф[её]|чайхан|choyxona)|marojni|muzqaymoq)/i;
5
+ /(офис[ _|,/.]|под ?офис|офисн|\boffice\b|\bofis\b|кеңсе|коммерческ|commercial|бизнес[ -]?центр|не\s?жил(?:ое|ой|ый|ых|ым|ого)|помещени[ея]|торгов(?:ое|ая) ?площад|торгов(?:ое|ое помещ)|warehouse|(?<![а-яёіїґ])склад(?!н|ыв)|производствен(?:ное|ых) ?помещ|spatiu comercial|(?:închiriez|inchiriez|vând|vand)\s+birou|birou\s+de\s+(?:închiriat|inchiriat|vânzare|vanzare)|\d+\s*sot(?:ix|ka)|\d+\s*сот(?:ок|ка|ки|ых)|yer\s*maydoni|bosh\s*yer|уч[аа]сток\s*земл|servis\s*uchun|kassaprav|avtomoyka|автомойк|car\s?wash|шиномонтаж|салон\s*красот|zallik\s*saloni|beauty\s*salon|beauty[\s-]?кабинет|космет(?:ическ|олог)[а-яё]*\s*кабинет|массажн[а-яё]*\s*кабинет|маникюрн[а-яё]*\s*(?:кабинет|студи)|nail\s*(?:bar|studio|salon)|аренда\s+(?:beauty\s+)?кабинет|парикмахерск|барбершоп|barbershop|аренда\s+рабоч(?:его|ее)\s*мест|рабоч(?:ее|его)\s*мест[оае]\s+(?:мастер|для\s+мастер|под\s+)|оренд[аи]\s+робоч(?:ого|е)\s*місц|аренда\s+гараж|гараж[ае]?\s+(?:аренд|сда[её]тся|ijara)|\bgaraj\b|\bgarage\b|o[\u2018\u2019\u02bb\u02bc'`ʻʼ]?quv\s*xona|o[\u2018\u2019\u02bb\u02bc'`ʻʼ]?quv\s*markaz|учебн(?:ый|ое|ая|ого)\s*(?:класс|помещ|кабинет|центр)|под\s+(?:бар|каф[её]|ресторан|магазин|салон|склад|бизнес|спортпит|спорт\s*пит|аптек|пекарн|пункт|шоурум|showroom|офис)|шоурум|showroom|спортпит|sportpit|\bsklad(?:lar)?\b|\bombor(?:xona)?\b|(?:avto\s*)?ser?vis\s+(?:arenda|ijara|beriladi)|авто\s?сервис\s+(?:аренд|сда[её]тся)|детейлинг|detailing|^\s*парковк|avtoturargoh|sot(?:ish|uv)\w*\s*joy|savdo\s*(?:uchun|joy)|(?:ijara\w*|arenda\w*|sot(?:ish|uv)\w*)\s+do[\u2018\u2019'`ʻʼ]?kon|do[\u2018\u2019'`ʻʼ]?kon\s+(?:ijara\w*|arenda\w*|sot(?:ish|uv)\w*)|(?:аренд|сда[её]т|сдам|прода|продаж)[а-яё]*\s+(?:(?:в|под|аренду|готов[а-яё]*|действующ[а-яё]*|срочно|отдельн[а-яё]*|стоящ[а-яё]*|цел[а-яё]*)\s+){0,3}(?:здани|каф[её])|бутик|[|,/]\s*(?:салон|бутик|аптек)|arenda\w*\s+joy|ijara\w*\s+joy|готов[а-яё]*\s+бизнес|tayyor\s+biznes|бизнес\s+(?:ресторан|каф[её]|магазин)|(?:сда[её]тся|аренда|прода[её]тся)\s+(?:готов[а-яё]*\s+)?(?:ресторан|каф[её]|чайхан|choyxona)|marojni|muzqaymoq)/i;
6
6
 
7
7
  export function looksCommercialHousing(value) {
8
8
  return value ? COMMERCIAL_HOUSING_RE.test(String(value)) : false;
@@ -9,6 +9,29 @@ const UZ_CONTEXTUAL_RENT_OUT_RE = /(?:^|[^\p{L}\p{N}_])(?:ijaraga|ижарага
9
9
  const UZ_PER_DAY_RE = /(?:^|[^\p{L}\p{N}_])(?:kuniga|кунига)(?=$|[^\p{L}\p{N}_])/iu;
10
10
  const UZ_DAILY_RENT_PRICE_RE = /(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)[^.!?\r\n]{0,48}(?:kuniga|кунига)|(?:kuniga|кунига)[^.!?\r\n]{0,48}(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)/iu;
11
11
 
12
+ // "ищу квартиру" / "шукаю квартиру" style aliases only match a literal,
13
+ // adjacent phrase. Real posts routinely insert a room count or adjective
14
+ // between the search verb and the housing noun ("Ищу 2-комнатную квартиру"),
15
+ // which left those posts unclassified -- neither offer nor wanted -- so they
16
+ // slipped past the propertyWanted filter and were shown as if someone were
17
+ // offering the flat. These contextual patterns allow a short bounded gap
18
+ // between the verb and the noun, and the gap is inspected for a "buy" marker
19
+ // so "ищу купить 2-комнатную квартиру" still resolves to buy, not rentIn.
20
+ const WANTED_GAP = '([^.!?\\r\\n]{0,40}?)';
21
+ const WANTED_CONTEXT_RE = Object.freeze([
22
+ new RegExp(`(?:^|[^\\p{L}\\p{N}_])(?:ищу|сниму|хочу\\s+снять|хочу\\s+купить|нужна|нужно|нужен)(?=$|[^\\p{L}\\p{N}_])${WANTED_GAP}(?:квартир\\p{L}*|жиль[ёе]\\p{L}*|комнат\\p{L}*)(?=$|[^\\p{L}\\p{N}_])`, 'iu'),
23
+ new RegExp(`(?:^|[^\\p{L}\\p{N}_])(?:шукаю|зніму|хочу\\s+зняти|хочу\\s+купити|потрібна|потрібно|потрібен)(?=$|[^\\p{L}\\p{N}_])${WANTED_GAP}(?:квартир\\p{L}*|житл\\p{L}*|кімнат\\p{L}*|будин\\p{L}*)(?=$|[^\\p{L}\\p{N}_])`, 'iu'),
24
+ ]);
25
+ const WANTED_BUY_MARKER_RE = /(?:купить|покупки|покупку|купити|купівлі)/iu;
26
+
27
+ function detectContextualWantedAction(text) {
28
+ for (const re of WANTED_CONTEXT_RE) {
29
+ const match = text.match(re);
30
+ if (match) return WANTED_BUY_MARKER_RE.test(match[1] || '') ? 'buy' : 'rentIn';
31
+ }
32
+ return null;
33
+ }
34
+
12
35
  export const HOUSING_ACTIONS = Object.freeze([
13
36
  group('sell', {
14
37
  ru: ['продам', 'продаю', 'продаётся', 'продается', 'выставил на продажу', 'выставила на продажу'],
@@ -112,7 +135,9 @@ export function resolveHousingIntent(value) {
112
135
  if (!text.trim()) return null;
113
136
 
114
137
  const actionMatch = findCanonical(text, HOUSING_ACTIONS, { partial: true });
115
- const action = actionMatch?.canonical || (UZ_CONTEXTUAL_RENT_OUT_RE.test(text) ? 'rentOut' : null);
138
+ const action = actionMatch?.canonical
139
+ || (UZ_CONTEXTUAL_RENT_OUT_RE.test(text) ? 'rentOut' : null)
140
+ || detectContextualWantedAction(text);
116
141
  const durationDeal = findCanonical(text, HOUSING_DEAL_TYPES, { partial: true });
117
142
  const hasContextualUzPerDay = UZ_PER_DAY_RE.test(text)
118
143
  && ((action === 'rentOut' || action === 'rentIn') || UZ_DAILY_RENT_PRICE_RE.test(text));