@whiteslove/parsing-lexicon 0.8.0 → 0.8.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.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contact.js CHANGED
@@ -9,6 +9,7 @@ const PHONE_LIKE_RE = /\+?\d(?:[\t \u00a0().-]*\d){9,}/g;
9
9
  const PHONE_CANDIDATE_RE = /\+?\d(?:[\t \u00a0().-]*\d){6,}(?:[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*\d{1,6})?/giu;
10
10
  const PHONE_EXTENSION_RE = /[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*(\d{1,6})$/iu;
11
11
  const DATE_LIKE_PHONE_RE = /^\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})(?:\s+\d{1,2})?$/u;
12
+ const PRICE_LABEL_BEFORE_NUMBER_RE = /(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\s+плата)?|rent)\s*[:=\-–—]?\s*$/iu;
12
13
 
13
14
  const TELEGRAM_USERNAME_RE = /^[A-Za-z0-9_]{5,32}$/;
14
15
  const TELEGRAM_LINK_RE = /(?:https?:\/\/)?(?:t\.me|telegram\.me|telegram\.dog)\/([A-Za-z0-9_]{5,32})(?:\/[0-9]+)?(?:[/?#][^\s]*)?/giu;
@@ -20,6 +21,18 @@ function normalizedCountryHint(value) {
20
21
  return /^[A-Z]{2}$/.test(country) ? country : undefined;
21
22
  }
22
23
 
24
+ // PHONE_LIKE_RE deliberately accepts punctuation-separated digit sequences.
25
+ // That also resembles a grouped monetary range such as
26
+ // "Narxi: 950 000 - 1.000.000". A nearby explicit price label is stronger
27
+ // semantic evidence than the broad (unvalidated) ten-digit phone mask, so do
28
+ // not hide that span before the money candidate parser sees it. This does
29
+ // not weaken validated national-phone parsing below.
30
+ function isExplicitPriceSpan(text, start, raw) {
31
+ if (!/[\-–—]/u.test(raw)) return false;
32
+ const before = text.slice(Math.max(0, start - 48), start);
33
+ return PRICE_LABEL_BEFORE_NUMBER_RE.test(before);
34
+ }
35
+
23
36
  function splitPhoneExtension(raw) {
24
37
  const match = String(raw || '').match(PHONE_EXTENSION_RE);
25
38
  if (!match) return { base: String(raw || '').trim(), extension: null };
@@ -38,6 +51,7 @@ export function findPhoneLikeSpans(value, options = {}) {
38
51
  const digits = raw.replace(/\D/g, '');
39
52
  if (digits.length < 10) continue;
40
53
  const start = match.index ?? 0;
54
+ if (isExplicitPriceSpan(text, start, raw)) continue;
41
55
  spans.push(Object.freeze({
42
56
  start,
43
57
  end: start + raw.length,
@@ -32,11 +32,15 @@ const APPLIANCE_PATTERNS = Object.freeze([
32
32
  const FIRST_RENT_UZ_RE = /(?:hali\s+hech\s+kim\s+(?:yashamagan|turmagan)|ҳали\s+ҳеч\s+ким\s+(?:яшамаган|турмаган))/iu;
33
33
  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;
34
34
  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 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;
35
+ 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
36
  const BROKER_RE = /(?:makler|vositachi|макл(?:ер[а-яё]*)?|ри[еэ]лтор[а-яё]*|агентств[а-яё]*|комисси[а-яё]*|broker|realtor|commission|comision(?:ul)?|agen[țt]ie|delda[lл]\p{L}*|делдал\p{L}*)/iu;
37
37
  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;
38
38
  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;
39
39
  const FAMILY_RE = /(?:семь\p{L}*|family|oila(?:ga|lar|li)?|oyla(?:ga|lar|li)?|oila\s+uchun|оилага|оелага|оилавий|oelaga|famil(?:ie|ia)|cuplu(?:ri)?|отбасына|отбасылы|жанұяға|сім['’ʼ]?[яїі](?:ям|ям[иі])?|сімейн\p{L}*)/iu;
40
+ // A category word in an exclusion clause is negative evidence, not a tenant
41
+ // preference. Keep this narrow and local to avoid rejecting ordinary
42
+ // "oila uchun" / "для семьи" invitations elsewhere in the listing.
43
+ const FAMILY_EXCLUSION_RE = /(?:oila|oyla|семь\p{L}*|family|famil(?:ie|ia)|cuplu(?:ri)?|отбас\p{L}*|жанұя\p{L}*)[^\r\n.!?]{0,48}(?:bezovta\s+qilmasin|murojaat\s+qilmasin|kerak\s+emas|qabul\s+qilinmaydi|не\s+(?:беспокоить|принимаем)|not\s+(?:allowed|welcome))/iu;
40
44
  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;
41
45
  const AIR_CONDITIONER_RE = /(?:кондицион|air\s*con|konditsioner|kandit(?:s|c)?aner|kanditsaner|кандитсанер)/iu;
42
46
  const PER_PERSON_PRICE_RE = /(?:kishi\s+boshiga|киши\s+бошига)\s*(\d{1,3}(?:[\s.,]\d{3})*|\d+(?:[.,]\d+)?)\s*(ming|минг|million|mln|млн)?(?:dan|дан)?/iu;
@@ -176,7 +180,7 @@ export function parseHousingNearby(value) {
176
180
 
177
181
  export function parseHousingAudience(value) {
178
182
  const text = normalizeUnicode(value ?? '');
179
- const family = FAMILY_RE.test(text);
183
+ const family = FAMILY_RE.test(text) && !FAMILY_EXCLUSION_RE.test(text);
180
184
  const women = WOMEN_RE.test(text);
181
185
  const men = MEN_RE.test(text);
182
186
  const students = STUDENT_RE.test(text);
@@ -10,7 +10,7 @@ export type HousingMoneyParseContext = Readonly<{
10
10
  fallbackCurrency?: string;
11
11
  dealType?: 'sale' | 'longRent' | 'shortRent' | string | null;
12
12
  }>;
13
- export type HousingMoneyCandidate = Readonly<{ amount: number; currency: string; start: number; end: number; explicitCurrency: boolean; scale: string | null; priceKeyword: boolean; paymentRole: string; approximate: boolean; confidenceBoost: number; confidence: number }>;
13
+ export type HousingMoneyCandidate = Readonly<{ amount: number; currency: string; start: number; end: number; explicitCurrency: boolean; scale: string | null; priceKeyword: boolean; range: Readonly<{ minimum: number; maximum: number }> | null; paymentRole: string; approximate: boolean; confidenceBoost: number; confidence: number }>;
14
14
  export function extractHousingMoneyCandidates(value: unknown, context?: string | HousingMoneyParseContext): readonly HousingMoneyCandidate[];
15
15
  export function rankHousingPriceCandidates(candidates: readonly HousingMoneyCandidate[]): readonly HousingMoneyCandidate[];
16
16
 
@@ -199,7 +199,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
199
199
  const text = maskPhoneLikeSpans(String(value || ''), ' ', { country });
200
200
  const candidates = [];
201
201
  const seen = new Set();
202
- const addCandidate = ({ amount, currency, start, end, explicitCurrency, scale = null, priceKeyword = false, confidenceBoost = 0 }) => {
202
+ const addCandidate = ({ amount, currency, start, end, explicitCurrency, scale = null, priceKeyword = false, range = null, confidenceBoost = 0 }) => {
203
203
  if (amount == null || amount < 1 || amount > 5_000_000_000 || seen.has(`${start}:${end}`)) return;
204
204
  seen.add(`${start}:${end}`);
205
205
  candidates.push(Object.freeze({
@@ -210,6 +210,7 @@ export function extractHousingMoneyCandidates(value, context = '') {
210
210
  explicitCurrency,
211
211
  scale,
212
212
  priceKeyword,
213
+ range,
213
214
  paymentRole: candidatePaymentRole(text, start, end),
214
215
  approximate: APPROXIMATE_RE.test(text.slice(Math.max(0, start - 12), end)),
215
216
  confidenceBoost,
@@ -239,6 +240,33 @@ export function extractHousingMoneyCandidates(value, context = '') {
239
240
  });
240
241
  }
241
242
 
243
+ // A price-labelled range is common in Telegram rentals even when the author
244
+ // omits "sum". In Uzbek country context, grouped endpoints are an explicit
245
+ // UZS signal; preserve both endpoints on the candidate while exposing the
246
+ // lower bound through the legacy single-price result. It must be collected
247
+ // before generic amounts so the second endpoint cannot be selected merely
248
+ // because it is larger.
249
+ const labelledPriceRangeRe = new RegExp(
250
+ `${PRICE_KEYWORD}\\s*[:=\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:-{1,3}|–|—|to|до|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})(?=$|[^\\p{L}\\p{N}_])`,
251
+ 'igu',
252
+ );
253
+ for (const match of text.matchAll(labelledPriceRangeRe)) {
254
+ const minimum = parseNumericAmount(match[1]);
255
+ const maximum = parseNumericAmount(match[2]);
256
+ if (minimum == null || maximum == null || minimum > maximum) continue;
257
+ const start = match.index ?? 0;
258
+ addCandidate({
259
+ amount: Math.round(minimum),
260
+ currency: country === 'UZ' ? 'UZS' : fallbackCurrency || '',
261
+ start,
262
+ end: start + match[0].length,
263
+ explicitCurrency: false,
264
+ priceKeyword: true,
265
+ range: Object.freeze({ minimum: Math.round(minimum), maximum: Math.round(maximum) }),
266
+ confidenceBoost: 0.2,
267
+ });
268
+ }
269
+
242
270
  const splitMillionRe = /(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/giu;
243
271
  for (const match of text.matchAll(splitMillionRe)) {
244
272
  const start = match.index ?? 0;
@@ -87,7 +87,7 @@ 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*(?:(?:-\s*)?комнат\p{L}*|(?:-\s*)?к(?:\.|\b)|(?:-\s*)?xona(?:li)?|(?:-\s*)?хона(?:лик|ли)?|бөлмелі|rooms?)(?=$|[^\p{L}\p{N}])/iu);
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);
91
91
  if (numeric) {
92
92
  const rooms = toNumber(numeric[1]);
93
93
  if (rooms != null && rooms >= 1 && rooms <= 20) return rooms;