@whiteslove/parsing-lexicon 0.3.4 → 0.3.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.3.4",
3
+ "version": "0.3.5",
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
@@ -20,7 +20,7 @@ export const TASHKENT_DISTRICTS = Object.freeze([
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
22
  entity('Yangihayot', { uzLatn: ['Yangihayot', 'Yangihayot tumani'], uzCyrl: ['Янгиҳаёт', 'Янгиҳаёт тумани', 'Янгихаёт'], ru: ['Янгихаётский район', 'Янгихаёт'], en: ['Yangihayot'] }),
23
- entity('Yashnobod', { uzLatn: ['Yashnobod', 'Yashnobod tumani'], uzCyrl: ['Яшнобод', 'Яшнобод тумани'], ru: ['Яшнабад', 'Яшнабадский район'], en: ['Yashnobod', 'Yashnabad'] }),
23
+ entity('Yashnobod', { uzLatn: ['Yashnobod', 'Yashnobod tumani', 'Yashnabod', 'Yashnabod tumani'], uzCyrl: ['Яшнобод', 'Яшнобод тумани'], ru: ['Яшнабад', 'Яшнабадский район'], en: ['Yashnobod', 'Yashnabad'] }),
24
24
  ]);
25
25
 
26
26
  export function canonicalTashkentDistrict(value) {
@@ -44,6 +44,7 @@ export interface HousingListingEnrichment {
44
44
  nearby?: readonly string[];
45
45
  amenities?: readonly string[];
46
46
  district?: string | null;
47
+ quarter?: { number: number; suffix: string } | null;
47
48
  metro?: string | null;
48
49
  residenceComplex?: string | null;
49
50
  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 } from './tashkent-housing-geography.js';
4
+ import { matchTashkentHousingDistrict, matchTashkentHousingMetro, matchTashkentHousingQuarter } from './tashkent-housing-geography.js';
5
5
  import { TASHKENT_LANDMARKS } from './tashkent-pois.js';
6
6
  import { parseHousingRoomCount, parseHousingFloor, parseHousingAreas } from './housing-structured.js';
7
7
  import { parseHousingListingFields } from './housing-listing-fields.js';
@@ -18,10 +18,10 @@ const GENERIC_CATEGORY = Object.freeze({
18
18
  });
19
19
 
20
20
  const APPLIANCE_PATTERNS = Object.freeze([
21
- ['Washing machine', /(?:кир\s*машин|стиральн\p{L}*\s+машин|washing\s+machine|kir\s*moshina|kirmoshina|kir\s*yuvish\s+mashin)/iu],
22
- ['Microwave', /(?:микроволнов|microwave|mikrovolnov|mikravolnof)/iu],
23
- ['Vacuum cleaner', /(?:пылесос|vacuum\s+cleaner|pilesos|pedesos)/iu],
24
- ['Oven', /(?:духовк|oven|duxovka|duhofka|dxofka)/iu],
21
+ ['Washing machine', /(?:кир\s*машин|кирмошин\p{L}*|стиральн\p{L}*\s+машин|washing\s+machine|kir\s*moshina|kirmoshina|kir\s*yuvish\s+mashin)/iu],
22
+ ['Microwave', /(?:микроволнов|микравалноф\p{L}*|microwave|mikrovolnov|mikravolnof)/iu],
23
+ ['Vacuum cleaner', /(?:пылесос|педесос|vacuum\s+cleaner|pilesos|pedesos)/iu],
24
+ ['Oven', /(?:духовк|дхофк\p{L}*|oven|duxovka|duhofka|dxofka)/iu],
25
25
  ['Kitchen', /(?:кухн|kitchen|oshxona|kuxn\p{L}*)/iu],
26
26
  ]);
27
27
 
@@ -141,6 +141,11 @@ const COMMISSION_AMOUNT_LEADING_RE = /(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\
141
141
 
142
142
  export function parseHousingCommissionAmount(value) {
143
143
  const text = normalizeUnicode(value ?? '');
144
+ // A listing that explicitly says "no broker/commission" cannot also have a
145
+ // commission amount — without this guard, an unrelated number near the
146
+ // negated broker word (e.g. a room count in "2 xonali ... maklersiz")
147
+ // could be misread as the commission amount.
148
+ if (NO_BROKER_RE.test(text)) return null;
144
149
  const match = text.match(COMMISSION_AMOUNT_RE) || text.match(COMMISSION_AMOUNT_LEADING_RE);
145
150
  if (!match) return null;
146
151
  const amount = Number(String(match[1]).replace(/\s+/g, '').replace(',', '.'));
@@ -193,7 +198,8 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
193
198
  const audience = parseHousingAudience(text);
194
199
  const perPersonPrice = parseHousingPerPersonPrice(text, { country });
195
200
  const observedAmenities = parseHousingObservedAmenities(text);
196
- const district = matchTashkentHousingDistrict(text)?.name || null;
201
+ const quarter = matchTashkentHousingQuarter(text);
202
+ const district = matchTashkentHousingDistrict(text)?.name || quarter?.district || null;
197
203
  const metro = matchTashkentHousingMetro(text)?.name || null;
198
204
  const parsedRc = specificResidentialComplex(text) || parseHousingResidentialComplex(text);
199
205
  const commission = parseHousingCommission(text);
@@ -243,6 +249,7 @@ export function parseHousingListingEnrichment(value, { country = '' } = {}) {
243
249
  nearby: parseHousingNearby(text),
244
250
  amenities: observedAmenities,
245
251
  district: district || null,
252
+ quarter: quarter ? { number: quarter.number, suffix: quarter.suffix } : null,
246
253
  metro: metro || null,
247
254
  residenceComplex: parsedRc || null,
248
255
  address: address.address,
@@ -49,6 +49,18 @@ function currencyNear(text) {
49
49
  export function parseHousingRoomCount(value) {
50
50
  const text = normalizeUnicode(value ?? '');
51
51
  if (!text) return null;
52
+
53
+ // Uzbek market shorthand: a unit formally registered as N rooms that was
54
+ // physically converted into a different count ("1 xonali ... 2 xona
55
+ // qilingan" — nominally 1-room, made into 2). The converted count is the
56
+ // one that matters and must win over the nominal count mentioned earlier
57
+ // in the same text.
58
+ const converted = text.match(/(\d{1,2})\s*(?:ta\s*)?xona(?:ga)?\s+(?:qilingan|aylantirilgan|bo['’ʻʼ‘`]?lingan)(?=$|[^\p{L}\p{N}_])/iu);
59
+ if (converted) {
60
+ const rooms = toNumber(converted[1]);
61
+ if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;
62
+ }
63
+
52
64
  const describedRooms = [...text.matchAll(/(?:^|[^\p{L}\p{N}_])(\d{1,2})\s*ta\s+(?:katta|kichkina)\s+xona(?=$|[^\p{L}\p{N}_])/giu)];
53
65
  if (describedRooms.length > 1) {
54
66
  const total = describedRooms.reduce((sum, match) => sum + Number(match[1]), 0);
@@ -44,5 +44,6 @@ export function matchTashkentNumberedArea(value: unknown, canonical: string): Ta
44
44
  export function hasTashkentAreaAlias(value: unknown, canonical: string): boolean;
45
45
  export function hasExplicitTashkentDistrict(value: unknown, canonical: string): boolean;
46
46
  export function matchTashkentHousingDistrict(value: unknown): TashkentDistrictEntry | null;
47
+ export function matchTashkentHousingQuarter(value: unknown): Readonly<{ district: string; number: number; suffix: string }> | null;
47
48
  export function matchTashkentHousingMetro(value: unknown): TashkentMetroEntry | null;
48
49
  export function matchTashkentHousingTransit(value: unknown): TashkentHousingLocationEntry | null;
@@ -252,6 +252,23 @@ export function matchTashkentHousingDistrict(value) {
252
252
  return null;
253
253
  }
254
254
 
255
+ /**
256
+ * Resolve a numbered massif/quarter mention (e.g. "Юнусабад-7 кв",
257
+ * "Yunusabad-7") that matchTashkentHousingDistrict() deliberately defers,
258
+ * returning the district it belongs to alongside the quarter number.
259
+ */
260
+ export function matchTashkentHousingQuarter(value) {
261
+ const text = String(value ?? '');
262
+ if (!text) return null;
263
+ for (const canonical of Object.keys(TASHKENT_NUMBERED_AREA_ALIASES)) {
264
+ const numbered = matchTashkentNumberedArea(text, canonical);
265
+ if (!numbered) continue;
266
+ const district = TASHKENT_HOUSING_DISTRICTS.find((entry) => entry.name === canonical) || null;
267
+ return Object.freeze({ district: district?.name || canonical, number: numbered.number, suffix: numbered.suffix });
268
+ }
269
+ return null;
270
+ }
271
+
255
272
  const EXTRA_METRO_ALIASES = Object.freeze({
256
273
  Sergeli: Object.freeze(['Sergile', 'Sergele']),
257
274
  });
@@ -272,6 +289,11 @@ export function matchTashkentHousingMetro(value) {
272
289
  const match = text.match(station.re);
273
290
  if (!match) continue;
274
291
  if (hasExplicitMetroContext(text, match)) return station;
292
+ // "Toshkent" is both a metro station and the city's own name, so a bare
293
+ // mention ("Toshkent shahri") is not evidence of the station the way a
294
+ // bare mention of any other station name would be. Require an explicit
295
+ // metro context for this one station specifically.
296
+ if (station.name === 'Toshkent') continue;
275
297
  if (station.name === 'Qoyliq' && QOYLIQ_MASSIF_RE.test(text)) continue;
276
298
  const areaCanonical = METRO_NUMBERED_AREA[station.name];
277
299
  if (areaCanonical && matchTashkentNumberedArea(text, areaCanonical)) continue;
@@ -177,10 +177,10 @@ export const UA_REGIONAL_LOCATION_EXTENSIONS = Object.freeze({
177
177
 
178
178
  Chernivtsi: city({
179
179
  microdistricts: [
180
- ['Tsentr','Центр'],['Prospekt','Проспект'],['Komarova','Комарова'],['Pivdenno-Kiltseva','Південно-Кільцева','Южно-Кольцевая'],['Hraviton','Гравітон','Гравитон'],['Kalichanka','Калічанка','Каличанка'],['Sadgora','Садгора','Садгірський','Садгірський район'],['Roscha','Роша'],['Klokuchka','Клокучка'],['Roscha-Stynka','Роша-Стинка'],['Tsetsyno','Цецино'],['Lenkivtsi','Ленківці','Ленковцы'],['Ruska','Руська'],['Fastivska','Фастівська'],
180
+ ['Tsentr','Центр'],['Prospekt','Проспект'],['Komarova','Комарова'],['Pivdenno-Kiltseva','Південно-Кільцева','Южно-Кольцевая'],['Hraviton','Гравітон','Гравитон'],['Kalichanka','Калічанка','Каличанка'],['Sadgora','Садгора','Садгірський','Садгірський район'],['Roscha','Роша'],['Klokuchka','Клокучка'],['Roscha-Stynka','Роша-Стинка'],['Tsetsyno','Цецино'],['Lenkivtsi','Ленківці','Ленковцы'],['Fastivska','Фастівська'],
181
181
  ],
182
182
  residentialComplexes: [
183
- ['Vodohrai','ЖК Водограй'],['Compass','ЖК Compass','ЖК Компас'],['Grand Royal','ЖК Grand Royal'],['Family House','ЖК Family House'],['Comfort Hall','ЖК Comfort Hall'],['Kyivskyi','ЖК Київський','ЖК Киевский'],['Rodynnyi','ЖК Родинний','ЖК Родинный'],['Panorama','ЖК Panorama'],['Park Avenue','ЖК Park Avenue'],['Central','ЖК Central'],
183
+ ['Vodohrai','ЖК Водограй'],['Compass','ЖК Compass','ЖК Компас'],['Comfort Hall','ЖК Comfort Hall'],['Kyivskyi','ЖК Київський','ЖК Киевский'],['Rodynnyi','ЖК Родинний','ЖК Родинный'],['Panorama','ЖК Panorama'],['Park Avenue','ЖК Park Avenue'],['Central','ЖК Central'],
184
184
  ],
185
185
  landmarks: [
186
186
  ['Metropolitan Residence','Резиденція митрополитів','Резиденция митрополитов'],['Chernivtsi University','ЧНУ','університет Федьковича'],['Teatralna Square','Театральна площа'],['Central Square','Центральна площа'],['Turkish Square','Турецька площа','Турецька криниця'],['Kobylianska Street','Ольги Кобилянської','вулиця Кобилянської'],['Shevchenko Park','парк Шевченка'],['Botanical Garden','Ботанічний сад'],['City Hall','Ратуша'],