@whiteslove/parsing-lexicon 0.6.6 → 0.6.8

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.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,9 @@ const SHOP_CHAINS = Object.freeze([
20
20
  ['Havas', /(?:^|[^\p{L}\p{N}_])[хxh]avas(?=$|[^\p{L}\p{N}_])|хавас/iu],
21
21
  ['Carrefour', /carrefour|карфур/iu],
22
22
  ['ATB', /(?:^|[^\p{L}\p{N}_])(?:атб|atb)(?=$|[^\p{L}\p{N}_])/iu],
23
- ['Klass', /(?:^|[^\p{L}\p{N}_])(?:klass|класс)(?=$|[^\p{L}\p{N}_])/iu],
23
+ // Hyphen excluded on both sides: the chain is a shop name, while
24
+ // "бизнес-класс" / "эконом-класс" / "премиум-класс" describe the housing.
25
+ ['Klass', /(?:^|[^\p{L}\p{N}_-])(?:klass|класс)(?=$|[^\p{L}\p{N}_-])/iu],
24
26
  ['Magnum', /magnum|магнум/iu],
25
27
  ['Bravo', /(?:^|[^\p{L}\p{N}_])bravo(?=$|[^\p{L}\p{N}_])|браво/iu],
26
28
  ['Metro C&C', /(?:^|[^\p{L}\p{N}_])metro\s*(?:cash|c\s*&\s*c|market)|метро\s*кэш/iu],
@@ -27,6 +27,11 @@ const NUMBER_WORDS = Object.freeze([
27
27
  [/(?<![\p{L}\p{N}_])(?:десятикомнатн\p{L}*|o['’]?n\s+xona(?:li)?|on\s+xona(?:li)?|ўн\s+хона(?:ли|лик)?|он\s+хона(?:ли|лик)?|10\s*(?:-\s*)?к(?:омн\p{L}*)?|10\s*(?:-\s*)?xona(?:li)?|10\s*(?:-\s*)?хона(?:лик|ли)?|ten[- ]bedroom|ten[- ]room)(?![\p{L}\p{N}_])/iu, 10],
28
28
  ]);
29
29
 
30
+ // Common classifieds shorthand: rooms/floor/total floors, optionally followed
31
+ // by the area ("2/10/16", "1/5/12-50m"). Requiring the shorthand to start its
32
+ // own line avoids reading dates in ordinary prose as housing data.
33
+ const COMPACT_SPEC_RE = /(?:^|[\r\n])[^\S\r\n]*(\d{1,2})[^\S\r\n]*[/\\][^\S\r\n]*(\d{1,3})[^\S\r\n]*[/\\][^\S\r\n]*(\d{1,3})(?:[^\S\r\n]*[-–—][^\S\r\n]*(\d{1,4}(?:[.,]\d{1,2})?)[^\S\r\n]*(?:м²|м2|m²|m2|кв\.?[^\S\r\n]*м|sqm|м|m)?)?(?=$|\s)/u;
34
+
30
35
  const FIRST_FLOOR_WORD_RE = /(?<![\p{L}\p{N}_])(?:перш(?:ий\s+поверх|ому\s+поверс(?:і|у))|(?:на\s+)?першому\s+поверсі|перв(?:ый\s+этаж|ом\s+этаже)|(?:на\s+)?первом\s+этаже)(?![\p{L}\p{N}_])/iu;
31
36
 
32
37
  function toNumber(value) {
@@ -36,6 +41,27 @@ function toNumber(value) {
36
41
  return Number.isFinite(number) ? number : null;
37
42
  }
38
43
 
44
+ // Reads the rooms/floor/total floors[-area] shorthand into its parts. All the
45
+ // range checks live here so the floor, room and area parsers share a single
46
+ // interpretation of the shorthand instead of each re-reading its numbers.
47
+ function parseCompactSpec(text) {
48
+ const match = text.match(COMPACT_SPEC_RE);
49
+ if (!match) return null;
50
+ const rooms = toNumber(match[1]);
51
+ const floor = toNumber(match[2]);
52
+ const totalFloors = toNumber(match[3]);
53
+ const areaSqm = toNumber(match[4]);
54
+ if (rooms == null || rooms < 1 || rooms > 20) return null;
55
+ if (floor == null || totalFloors == null) return null;
56
+ if (floor < 1 || floor > totalFloors || totalFloors > 200) return null;
57
+ return {
58
+ rooms,
59
+ floor,
60
+ totalFloors,
61
+ areaSqm: areaSqm != null && areaSqm > 0 && areaSqm <= 100000 ? areaSqm : null,
62
+ };
63
+ }
64
+
39
65
  function currencyNear(text) {
40
66
  // findCanonical's partial match requires the alias to be its own
41
67
  // space-delimited token, so a currency symbol glued directly to its
@@ -77,31 +103,29 @@ export function parseHousingRoomCount(value) {
77
103
  // put the label before the number: "Комнат - 4" instead of "4 комнаты".
78
104
  const reversed = text.match(/(?:комнат\p{L}*|xona(?:lar)?(?:i)?|хона(?:лар)?(?:и|лик|ли)?|rooms?)\s*[-:–—]\s*(\d{1,2})(?=$|[^\p{L}\p{N}])/iu);
79
105
  const reversedRooms = toNumber(reversed?.[1]);
80
- return reversedRooms != null && reversedRooms >= 1 && reversedRooms <= 20 ? reversedRooms : null;
106
+ if (reversedRooms != null && reversedRooms >= 1 && reversedRooms <= 20) return reversedRooms;
107
+
108
+ // Listings that only carry the compact "1/5/12-50m" shorthand state the room
109
+ // count in its first number.
110
+ return parseCompactSpec(text)?.rooms ?? null;
81
111
  }
82
112
 
83
113
  export function parseHousingFloor(value) {
84
114
  const text = normalizeUnicode(value ?? '');
85
115
  if (!text) return deepFreeze({ floor: null, totalFloors: null });
86
116
 
87
- // Common classifieds shorthand is rooms/floor/total floors, e.g. 2/10/16.
88
- // It must be checked before the generic floor/total fraction parser or the
89
- // first two numbers would incorrectly become floor=2, totalFloors=10.
90
- // Requiring the triple to occupy its own line avoids treating dates as this
91
- // housing-specific shorthand inside ordinary prose.
92
- const compactTriple = text.match(/(?:^|[\r\n])\s*(\d{1,2})\s*[\/\\]\s*(\d{1,3})\s*[\/\\]\s*(\d{1,3})\s*(?=$|[\r\n])/u);
93
- if (compactTriple) {
94
- const rooms = toNumber(compactTriple[1]);
95
- const floor = toNumber(compactTriple[2]);
96
- const totalFloors = toNumber(compactTriple[3]);
97
- if (rooms != null && rooms >= 1 && rooms <= 20
98
- && floor != null && totalFloors != null
99
- && floor >= 1 && floor <= totalFloors && totalFloors <= 200) {
100
- return deepFreeze({ floor, totalFloors });
101
- }
117
+ // The rooms/floor/total shorthand must be read before the generic
118
+ // floor/total fraction parser or its first two numbers would incorrectly
119
+ // become floor=2, totalFloors=10 for "2/10/16".
120
+ const compactSpec = parseCompactSpec(text);
121
+ if (compactSpec) {
122
+ return deepFreeze({ floor: compactSpec.floor, totalFloors: compactSpec.totalFloors });
102
123
  }
103
124
 
104
- const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*[\/\\]\s*(\d{1,3})(?=$|[^\d])/u);
125
+ // Skip a pair that is merely the head of a rooms/floor/total triple the
126
+ // shorthand parser above rejected: there the floor and the total floors are
127
+ // the second and third numbers, never the first two.
128
+ const fraction = text.match(/(?:^|[^\d])(\d{1,3})\s*[\/\\]\s*(\d{1,3})(?!\s*[\/\\]\s*\d)(?=$|[^\d])/u);
105
129
  if (fraction) {
106
130
  const floor = toNumber(fraction[1]);
107
131
  const totalFloors = toNumber(fraction[2]);
@@ -176,6 +200,9 @@ export function parseHousingAreas(value) {
176
200
  const generic = text.match(new RegExp(`(?:^|[^\\d])(\\d{1,4}(?:[.,]\\d{1,2})?)\\s*${AREA_UNIT_RE}(?=$|[^\\p{L}\\p{N}])`, 'iu'));
177
201
  result.total = toNumber(generic?.[1]);
178
202
  }
203
+ // The compact "1/5/12-50m" shorthand glues the area to the floor block, where
204
+ // the unit is often a bare "m" the generic area matcher cannot trust alone.
205
+ if (result.total == null) result.total = parseCompactSpec(text)?.areaSqm ?? null;
179
206
  for (const key of Object.keys(result)) {
180
207
  const number = result[key];
181
208
  if (number != null && (number <= 0 || number > 100000)) result[key] = null;
@@ -150,6 +150,7 @@ export const TASHKENT_RESIDENTIAL_COMPLEXES = Object.freeze([
150
150
  complex('Olmos Residence'),
151
151
  complex('Olmazor City', ['Алмазар Сити', 'Олмазор Сити']),
152
152
  complex('Orzu'),
153
+ complex('Orzu Saroyi', ['Орзу Саройи', 'Orzu Saroy', 'Орзу Сарой']),
153
154
  complex('OQSAROY', ['ОК САРОЙ', 'Оксарой', 'Oq Saroy', 'Oq-Saroy']),
154
155
  complex('O\'zbegim', ['Узбегим', 'O‘zbegim', 'Oʻzbegim', 'Ozbegim']),
155
156
  complex('O\'z Mahal', ['Озмахал', 'Узмахал', 'O‘z Mahal', 'Oʻz Mahal', 'Oz Mahal']),