@whiteslove/parsing-lexicon 0.2.6 → 0.2.7

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/index.d.ts CHANGED
@@ -373,6 +373,11 @@ export * from './src/housing-context.js';
373
373
  export * from './src/hiring-context.js';
374
374
  export * from './src/housing-intent.js';
375
375
  export * from './src/housing-structured.js';
376
+ export * from './src/housing-safety.js';
377
+ export * from './src/housing-title.js';
378
+ export * from './src/housing-language.js';
379
+ export * from './src/housing-features.js';
380
+ export * from './src/housing-listing-fields.js';
376
381
 
377
382
  // housing semantic helper declarations added in 0.2.5
378
383
  export function resolveHousingOccupancy(value: unknown): 'wholeProperty' | 'room' | 'sharedRoom' | 'bedSpace' | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for Whiteslove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,6 +41,11 @@
41
41
  "./housing-context": { "types": "./src/housing-context.d.ts", "import": "./src/housing-context.js" },
42
42
  "./housing-address": { "types": "./src/housing-address.d.ts", "import": "./src/housing-address.js" },
43
43
  "./housing-structured": { "types": "./src/housing-structured.d.ts", "import": "./src/housing-structured.js" },
44
+ "./housing-safety": { "types": "./src/housing-safety.d.ts", "import": "./src/housing-safety.js" },
45
+ "./housing-title": { "types": "./src/housing-title.d.ts", "import": "./src/housing-title.js" },
46
+ "./housing-language": { "types": "./src/housing-language.d.ts", "import": "./src/housing-language.js" },
47
+ "./housing-features": { "types": "./src/housing-features.d.ts", "import": "./src/housing-features.js" },
48
+ "./housing-listing-fields": { "types": "./src/housing-listing-fields.d.ts", "import": "./src/housing-listing-fields.js" },
44
49
  "./housing-text": { "types": "./src/housing-text.d.ts", "import": "./src/housing-text.js" },
45
50
  "./housing-source-aliases": { "types": "./src/housing-source-aliases.d.ts", "import": "./src/housing-source-aliases.js" },
46
51
  "./hiring": "./src/hiring.js",
@@ -0,0 +1,8 @@
1
+ export type HousingFeatures = Readonly<{
2
+ internet: boolean | null;
3
+ courtyard: boolean | null;
4
+ gazebo: boolean | null;
5
+ petsAllowed: boolean | null;
6
+ }>;
7
+
8
+ export function parseHousingFeatures(value: unknown): HousingFeatures;
@@ -0,0 +1,4 @@
1
+ export type HousingTextLanguage = 'ru' | 'uk' | 'en' | 'uz';
2
+
3
+ export function housingTextIsInLanguage(value: unknown, language: string): boolean;
4
+ export function detectHousingTextLanguage(value: unknown): HousingTextLanguage | null;
@@ -0,0 +1,73 @@
1
+ import { normalizeUnicode } from './normalization.js';
2
+
3
+ // Uzbek and Kazakh Cyrillic carry letters no Slavic alphabet uses. Their
4
+ // presence rules out "this text is already Russian/Ukrainian" even though the
5
+ // housing vocabulary overlaps heavily. `і` is deliberately absent: it is an
6
+ // ordinary Ukrainian letter.
7
+ const NON_SLAVIC_CYRILLIC = /[ўқғҳәөүұңһ]/iu;
8
+ const SLAVIC_LANGUAGES = new Set(['ru', 'uk']);
9
+
10
+ /**
11
+ * Housing vocabulary that reliably indicates the language a listing body is
12
+ * written in. Deliberately common words: the goal is deciding whether a reader
13
+ * needs a translation, not identifying rare dialects.
14
+ */
15
+ const HOUSING_LANGUAGE_SIGNALS = Object.freeze({
16
+ ru: {
17
+ pattern: /(?:квартир\p{L}*|комнат\p{L}*|этаж\p{L}*|дом\p{L}*|цен\p{L}*|сда[её]тся|прода[её]тся|аренд\p{L}*|рядом|метро|семейн\p{L}*|коммунальн\p{L}*|ремонт\p{L}*|мебел\p{L}*|балкон\p{L}*|район\p{L}*)/giu,
18
+ minimum: 2,
19
+ },
20
+ uk: {
21
+ pattern: /(?:квартир\p{L}*|кімнат\p{L}*|поверх\p{L}*|будинк\p{L}*|цін\p{L}*|здається|продається|оренд\p{L}*|поруч|метро|сімейн\p{L}*|комунальн\p{L}*|ремонт\p{L}*|меблі\p{L}*|балкон\p{L}*|район\p{L}*)/giu,
22
+ minimum: 2,
23
+ },
24
+ en: {
25
+ pattern: /(?:^|[^\p{L}])(?:apartment|flat|house|room|bedroom|floor|price|rent|rental|sale|family|utilities|near|available|furnished|balcony|district|deposit)(?=$|[^\p{L}])/giu,
26
+ minimum: 3,
27
+ },
28
+ uz: {
29
+ pattern: /(?:kvartira\p{L}*|xona\p{L}*|qavat\p{L}*|uy\p{L}*|narx\p{L}*|ijara\p{L}*|beriladi|sotiladi|yaqin|metro|mebel\p{L}*|balkon\p{L}*|tuman\p{L}*)/giu,
30
+ minimum: 2,
31
+ },
32
+ });
33
+
34
+ function signalCount(text, language) {
35
+ const signal = HOUSING_LANGUAGE_SIGNALS[language];
36
+ if (!signal) return 0;
37
+ return (text.match(signal.pattern) || []).length;
38
+ }
39
+
40
+ /**
41
+ * True when the housing text already reads as the given language, i.e. a reader
42
+ * of that language does not need it translated.
43
+ *
44
+ * Uses a per-language evidence threshold rather than a single hit, because one
45
+ * shared word ("metro", "balkon") appears across all of these languages.
46
+ */
47
+ export function housingTextIsInLanguage(value, language) {
48
+ const text = normalizeUnicode(value ?? '').toLocaleLowerCase();
49
+ if (!text.trim()) return false;
50
+ const signal = HOUSING_LANGUAGE_SIGNALS[language];
51
+ if (!signal) return false;
52
+ // Uzbek/Kazakh Cyrillic text reuses Slavic housing words but is neither.
53
+ if (SLAVIC_LANGUAGES.has(language) && NON_SLAVIC_CYRILLIC.test(text)) return false;
54
+ return signalCount(text, language) >= signal.minimum;
55
+ }
56
+
57
+ /**
58
+ * Best-guess language of a housing text, or null when no language reaches its
59
+ * evidence threshold.
60
+ */
61
+ export function detectHousingTextLanguage(value) {
62
+ const text = normalizeUnicode(value ?? '').toLocaleLowerCase();
63
+ if (!text.trim()) return null;
64
+
65
+ let best = null;
66
+ for (const language of Object.keys(HOUSING_LANGUAGE_SIGNALS)) {
67
+ if (SLAVIC_LANGUAGES.has(language) && NON_SLAVIC_CYRILLIC.test(text)) continue;
68
+ const count = signalCount(text, language);
69
+ if (count < HOUSING_LANGUAGE_SIGNALS[language].minimum) continue;
70
+ if (!best || count > best.count) best = { language, count };
71
+ }
72
+ return best?.language ?? null;
73
+ }
@@ -0,0 +1,49 @@
1
+ export type HousingMinRentTerm = Readonly<{
2
+ value: number;
3
+ unit: 'day' | 'week' | 'month' | 'year';
4
+ }>;
5
+
6
+ export type HousingUtilitiesAmount = Readonly<{
7
+ amount: number;
8
+ currency: string | null;
9
+ approximate: boolean;
10
+ }>;
11
+
12
+ export type HousingListingFields = Readonly<{
13
+ bedrooms: number | null;
14
+ bathrooms: number | null;
15
+ buildingYear: number | null;
16
+ balcony: boolean | null;
17
+ terrace: boolean | null;
18
+ privateYard: boolean | null;
19
+ courtyard: boolean | null;
20
+ gazebo: boolean | null;
21
+ dishwasher: boolean | null;
22
+ airConditioner: boolean | null;
23
+ gas: boolean | null;
24
+ newBuilding: boolean | null;
25
+ communalSeparated: boolean | null;
26
+ parking: boolean | null;
27
+ elevator: boolean | null;
28
+ heating: boolean | null;
29
+ hotWater: boolean | null;
30
+ internet: boolean | null;
31
+ petsAllowed: boolean | null;
32
+ childrenAllowed: boolean | null;
33
+ smokingAllowed: boolean | null;
34
+ negotiable: boolean | null;
35
+ furnished: boolean | null;
36
+ depositRequired: boolean | null;
37
+ firstRent: boolean | null;
38
+ minRentTerm: HousingMinRentTerm | null;
39
+ availableFrom: string | null;
40
+ utilitiesAmount: HousingUtilitiesAmount | null;
41
+ }>;
42
+
43
+ /**
44
+ * Empty input yields an empty object, so every field is optional on the result.
45
+ */
46
+ export function parseHousingListingFields(
47
+ value: unknown,
48
+ options?: { country?: string },
49
+ ): Readonly<Partial<HousingListingFields>>;
@@ -0,0 +1,7 @@
1
+ export type HousingSafetySignals = Readonly<{
2
+ roomOnly: boolean;
3
+ singleFemaleTenantSought: boolean;
4
+ }>;
5
+
6
+ export function seeksSingleFemaleTenant(value: unknown): boolean;
7
+ export function parseHousingSafetySignals(value: unknown): HousingSafetySignals;
@@ -0,0 +1,75 @@
1
+ import { deepFreeze } from './lexicon-core.js';
2
+ import { normalizeUnicode } from './normalization.js';
3
+ import { isRoomOnlyHousing } from './housing-source-aliases.js';
4
+
5
+ // Clause-scoped gap: the demand and the person must sit in the same sentence.
6
+ // Crossing `.`/`!`/`?`/newline let "Ищу жильё. Одна девушка уже живёт" read as a
7
+ // demand for one woman, which it is not.
8
+ const CLAUSE = String.raw`[^\r\n.!?]`;
9
+
10
+ /**
11
+ * Wording that seeks exactly one female tenant, as opposed to the generic
12
+ * "women only" audience wording that ordinary women-only listings use.
13
+ * The count matters: a landlord addressing one specific woman is the signal,
14
+ * not a flat that happens to prefer female tenants.
15
+ */
16
+ const SINGLE_FEMALE_TENANT_PATTERNS = Object.freeze([
17
+ // ru: "только одна девушка", "нужна 1 девушка", "ищу одну женщину", "подселю одну девушку"
18
+ new RegExp(
19
+ String.raw`(?:только|лише|нужн\p{L}*|потрібн\p{L}*|ищ[еуy]\p{L}*|шука\p{L}*|подсел\p{L}*|підсел\p{L}*)`
20
+ + CLAUSE + `{0,24}`
21
+ + String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у|ту)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`,
22
+ 'iu',
23
+ ),
24
+ // uk/ru reversed order: "одна девушка нужна"
25
+ new RegExp(
26
+ String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`
27
+ + CLAUSE + `{0,18}`
28
+ + String.raw`(?:нужн\p{L}*|потрібн\p{L}*|треба|ищ[еуy]\p{L}*|шука\p{L}*)`,
29
+ 'iu',
30
+ ),
31
+ // uzLatn: "faqat 1 ta qiz kerak", "bitta ayol ijarachi kerak"
32
+ new RegExp(
33
+ String.raw`(?:faqat\s+)?(?:^|[^\p{L}\p{N}_])(?:1|bitta)\s*(?:ta\s*)?(?:qiz|ayol)`
34
+ + CLAUSE + `{0,18}`
35
+ + String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)`,
36
+ 'iu',
37
+ ),
38
+ // uzCyrl: "фақат 1 та қиз керак"
39
+ new RegExp(
40
+ String.raw`(?:фақат\s+)?(?:^|[^\p{L}\p{N}_])(?:1|битта)\s*(?:та\s*)?(?:қиз|аёл)`
41
+ + CLAUSE + `{0,18}`
42
+ + String.raw`(?:ижарачи\s*)?(?:керак|керакли)`,
43
+ 'iu',
44
+ ),
45
+ ]);
46
+
47
+ /**
48
+ * True when the text asks for exactly one female tenant.
49
+ *
50
+ * Deliberately narrower than the `women` audience: "только для девушек" is an
51
+ * ordinary preference and must not match, while "нужна одна девушка" does.
52
+ */
53
+ export function seeksSingleFemaleTenant(value) {
54
+ const text = normalizeUnicode(value ?? '');
55
+ if (!text.trim()) return false;
56
+ return SINGLE_FEMALE_TENANT_PATTERNS.some((pattern) => pattern.test(text));
57
+ }
58
+
59
+ /**
60
+ * Linguistic safety signals for a housing listing.
61
+ *
62
+ * This reports only what the wording says. Whether a given combination is
63
+ * treated as a risk — and any price policy applied on top — belongs to the
64
+ * consuming service, not to the lexicon.
65
+ */
66
+ export function parseHousingSafetySignals(value) {
67
+ const text = normalizeUnicode(value ?? '');
68
+ if (!text.trim()) {
69
+ return deepFreeze({ roomOnly: false, singleFemaleTenantSought: false });
70
+ }
71
+ return deepFreeze({
72
+ roomOnly: isRoomOnlyHousing(text),
73
+ singleFemaleTenantSought: seeksSingleFemaleTenant(text),
74
+ });
75
+ }
@@ -0,0 +1,2 @@
1
+ export function isGenericHousingTitle(value: unknown): boolean;
2
+ export function hasMeaningfulHousingTitle(value: unknown): boolean;
@@ -0,0 +1,70 @@
1
+ import { normalizeUnicode } from './normalization.js';
2
+
3
+ /**
4
+ * Marketplace category headings that some sources (notably OLX) hand back in
5
+ * place of a real listing title. They name a whole search category rather than
6
+ * one property, so a consumer must not present them as the listing's own title.
7
+ *
8
+ * The English forms already embed the subject ("long-term apartment rentals"),
9
+ * so the subject is matched as an optional trailing word rather than required.
10
+ */
11
+ const CATEGORY = String.raw`(?:`
12
+ + String.raw`(?:довгостроков\p{L}*|долгосрочн\p{L}*|короткостроков\p{L}*|краткосрочн\p{L}*|подобов\p{L}*|посуточн\p{L}*)\s+(?:оренда|аренда|найм)`
13
+ + String.raw`|(?:оренда|аренда|найм|продаж|продажа)`
14
+ + String.raw`|(?:long|short)[- ]?term\s+(?:apartment\s+|flat\s+|house\s+|room\s+)?rentals?`
15
+ + String.raw`|(?:apartments?|flats?|houses?|rooms?)\s+for\s+(?:rent|sale)`
16
+ + String.raw`|rentals?|daily\s+rentals?`
17
+ + String.raw`|ijaraga\s+berish|ijara|sotuvi`
18
+ + String.raw`)`;
19
+
20
+ const SUBJECT = String.raw`(?:`
21
+ + String.raw`квартир\p{L}*|кімнат\p{L}*|комнат\p{L}*|будинк\p{L}*|будинків|дом\p{L}*|житл\p{L}*|нерухомост\p{L}*|недвижимост\p{L}*`
22
+ + String.raw`|apartments?|flats?|houses?|rooms?|property|real\s+estate`
23
+ + String.raw`|kvartira\p{L}*|uylar|xona\p{L}*`
24
+ + String.raw`)`;
25
+
26
+ // Only a single trailing locality clause counts as part of the heading — one
27
+ // short comma- or dash-separated fragment such as ", Подільський район".
28
+ // Anything richer means the title is saying something about this property.
29
+ const LOCALITY_TAIL = String.raw`(?:\s*[,–—-]\s*[^\r\n,]{1,40})?`;
30
+
31
+ const GENERIC_TITLE_PATTERN = new RegExp(
32
+ `^\\s*${CATEGORY}(?:\\s+${SUBJECT})?${LOCALITY_TAIL}\\s*$`,
33
+ 'iu',
34
+ );
35
+
36
+ // Digits are the cheapest proof a title says something specific: a room count,
37
+ // an area, a price or a street number. "2-к квартира, 54 м²" is a real title
38
+ // even though it opens with the same words as the category heading.
39
+ const SPECIFIC_DETAIL = /\d/u;
40
+
41
+ function titleContent(value) {
42
+ return normalizeUnicode(value ?? '')
43
+ .replace(
44
+ /[\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Modifier}\p{Variation_Selector}\p{Join_Control}]/gu,
45
+ '',
46
+ )
47
+ .replace(/[^\p{L}\p{N}]+/gu, '');
48
+ }
49
+
50
+ /**
51
+ * True when the title is a marketplace category heading rather than a title
52
+ * describing this specific property.
53
+ */
54
+ export function isGenericHousingTitle(value) {
55
+ const text = normalizeUnicode(value ?? '').replace(/\s+/g, ' ').trim();
56
+ if (!text) return false;
57
+ if (SPECIFIC_DETAIL.test(text)) return false;
58
+ return GENERIC_TITLE_PATTERN.test(text);
59
+ }
60
+
61
+ /**
62
+ * True when the title can stand on its own in a listing card or popup.
63
+ *
64
+ * Rejects titles carrying almost no letters or digits (emoji- or
65
+ * punctuation-only) as well as marketplace category headings.
66
+ */
67
+ export function hasMeaningfulHousingTitle(value) {
68
+ if (titleContent(value).length < 3) return false;
69
+ return !isGenericHousingTitle(value);
70
+ }
package/src/index.js CHANGED
@@ -36,6 +36,9 @@ export * from './housing-context.js';
36
36
  export * from './housing-address.js';
37
37
  export * from './housing-features.js';
38
38
  export * from './housing-listing-fields.js';
39
+ export * from './housing-safety.js';
40
+ export * from './housing-title.js';
41
+ export * from './housing-language.js';
39
42
  export * from './housing-structured.js';
40
43
  export * from './hiring.js';
41
44
  export * from './hiring-languages.js';