@whiteslove/parsing-lexicon 0.2.6 → 0.2.9

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.9",
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",
package/src/contact.js CHANGED
@@ -166,7 +166,9 @@ export function parsePrimaryContact(value) {
166
166
  const digits = intl[0].replace(/\D/g, '');
167
167
  if (digits.length >= 10 && digits.length <= 15) return `+${digits}`;
168
168
  }
169
- const keyword = text.match(/(?:tel|тел|phone|моб|whats?app|viber|telegram|звонит|звоніть|aloqa|byla|contact)[^\d+]{0,8}(\+?\d[\d\s().-]{6,}\d)/iu);
169
+ // Bounded like the `trailing` keyword below: "тел"/"phone" must be a whole
170
+ // word, not a suffix of an unrelated word ("хостел", "котел").
171
+ const keyword = text.match(/(?<![\p{L}\p{N}_])(?:tel|тел|phone|моб|whats?app|viber|telegram|звонит|звоніть|aloqa|byla|contact)(?![\p{L}\p{N}_])[^\d+]{0,8}(\+?\d[\d\s().-]{6,}\d)/iu);
170
172
  if (keyword) {
171
173
  const digits = keyword[1].replace(/\D/g, '');
172
174
  if (digits.length >= 9 && digits.length <= 15) return keyword[1].trim();
@@ -0,0 +1,22 @@
1
+ import { parseHiringContext as parseBaseHiringContext } from './hiring-context.js';
2
+
3
+ const EXPLICIT_POSITIVE_VISA_SPONSORSHIP_RE = /\bwe\s+do\s+sponsor\s+visas?\b/i;
4
+
5
+ /**
6
+ * Compatibility refinements for real-world hiring copy that is intentionally
7
+ * more specific than the generic alias catalog. Keep this wrapper tiny so the
8
+ * base multilingual context parser remains the single source of truth.
9
+ */
10
+ export function parseHiringContext(value, options = {}) {
11
+ const text = String(value || '');
12
+ const parsed = parseBaseHiringContext(text, options);
13
+ if (!EXPLICIT_POSITIVE_VISA_SPONSORSHIP_RE.test(text)
14
+ || parsed.workAuthorization.includes('sponsorshipOffered')) {
15
+ return parsed;
16
+ }
17
+
18
+ return Object.freeze({
19
+ ...parsed,
20
+ workAuthorization: Object.freeze([...parsed.workAuthorization, 'sponsorshipOffered']),
21
+ });
22
+ }
@@ -19,7 +19,10 @@ const STATUS_ONLY_RE = /^(?:talaba|student|студент(?:ка)?|студен
19
19
  const FLEXIBLE_ROLE_RE = /^(?:нет|без)\s+разницы(?:\s+.*)?$|^не\s*важно(?:\s+.*)?$|^farqi\s+yo['’ʻʼ‘`]?q$|^любая\s+(?:работа|занятость)(?:\s+.*)?$/iu;
20
20
  const NON_ROLE_RE = /^(?:удал[её]нно|работа\s+на\s+удал[её]н\p{L}*\s+основе|remote|onlayn|online|онлайн|farqi\s+yo['’ʻʼ‘`]?q|bilmaym\p{L}*|ish\s+ker(?:e|a)\s+onlayn|любая\s+(?:работа|занятость)|немає|нет|не\s+указано|not\s+specified)$/iu;
21
21
  const REMOTE_NEGATIVE_RE = /(?:onsite|on-site|office\s+only|тільки\s+офіс|только\s+офис|офисн(?:ый|ая)\s+формат|офлайн|без\s+удал[её]нк|удал[её]нк\p{L}*\s+не\s+рассматрива|не\s+рассматрива\p{L}*\s+удал[её]н|remote\s+(?:not|no)|faqat\s+ofis|ofisda\s+ish(?:lash)?)/iu;
22
- const REMOTE_POSITIVE_RE = /(?:\bremote\b|\bremotely\b|удал[её]н(?:но|ка|ный|ная|н\p{L}*)?|віддален(?:о|а|ий)?|дистанц(?:ионно|ійно)|masofaviy|(?<!\p{L})onlayn(?!\p{L})|online\s+(?:work|job)|онлайн\s+работ)/iu;
22
+ // The open `\p{L}*` suffixes used to match "удаленность"/"віддаленості" (an
23
+ // object's distance from something) as a remote-work preference. Case
24
+ // endings are now enumerated explicitly and both stems are token-bounded.
25
+ const REMOTE_POSITIVE_RE = /(?:\bremote\b|\bremotely\b|(?<!\p{L})удал[её]н(?:но|ка|н(?:ый|ая|ую|ых|ым|ой|ое|ые))?(?!\p{L})|(?<!\p{L})віддален(?:о|а|ий)?(?!\p{L})|дистанц(?:ионно|ійно)|masofaviy|(?<!\p{L})onlayn(?!\p{L})|online\s+(?:work|job)|онлайн\s+работ)/iu;
23
26
 
24
27
  const FEATURE_RULES = Object.freeze([
25
28
  ['student', /\bstudent\b|студент|студентк|talaba/iu],
@@ -197,7 +197,12 @@ export function detectVisaSponsorshipWording(value) {
197
197
  return null;
198
198
  }
199
199
 
200
- export const TEMPORARY_WORK_AUTH_RE = /\b(?:opt|cpt|stem\s+opt)\b/iu;
200
+ // Bare "opt" needs case-sensitive "OPT" (the Optional Practical Training
201
+ // abbreviation is written that way in real postings) — a case-insensitive
202
+ // `\bopt\b` also matched the common English verb ("opt-in", "opt out").
203
+ // "cpt" and "stem opt" don't collide with ordinary words, so they stay
204
+ // case-insensitive via explicit character classes instead of the `i` flag.
205
+ export const TEMPORARY_WORK_AUTH_RE = /\b(?:[Cc][Pp][Tt]|[Ss][Tt][Ee][Mm]\s+[Oo][Pp][Tt])\b|\bOPT\b/u;
201
206
 
202
207
  export function detectRecruitmentAgency(value) {
203
208
  return /recruit(?:ment|ing) agency|staffing agency|talent agency|кадров(?:ое|е) агентство|рекрут(?:ингов|инг)\p{L}* агентство|агентство по подбору/iu.test(String(value || ''));
@@ -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' | 'ro' | 'kk';
2
+
3
+ export function housingTextIsInLanguage(value: unknown, language: string): boolean;
4
+ export function detectHousingTextLanguage(value: unknown): HousingTextLanguage | null;
@@ -0,0 +1,81 @@
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
+ ro: {
33
+ pattern: /(?:apartament\p{L}*|cameră\p{L}*|camera\p{L}*|etaj\p{L}*|cas[aă]\p{L}*|case\p{L}*|pre[țt]\p{L}*|închiri\p{L}*|inchiri\p{L}*|vânz\p{L}*|vanz\p{L}*|metrou\p{L}*|famil\p{L}*|utilit\p{L}*|mobilat\p{L}*|balcon\p{L}*|cartier\p{L}*)/giu,
34
+ minimum: 2,
35
+ },
36
+ kk: {
37
+ 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}*|жиһаз\p{L}*|балкон\p{L}*|аудан\p{L}*)/giu,
38
+ minimum: 2,
39
+ },
40
+ });
41
+
42
+ function signalCount(text, language) {
43
+ const signal = HOUSING_LANGUAGE_SIGNALS[language];
44
+ if (!signal) return 0;
45
+ return (text.match(signal.pattern) || []).length;
46
+ }
47
+
48
+ /**
49
+ * True when the housing text already reads as the given language, i.e. a reader
50
+ * of that language does not need it translated.
51
+ *
52
+ * Uses a per-language evidence threshold rather than a single hit, because one
53
+ * shared word ("metro", "balkon") appears across all of these languages.
54
+ */
55
+ export function housingTextIsInLanguage(value, language) {
56
+ const text = normalizeUnicode(value ?? '').toLocaleLowerCase();
57
+ if (!text.trim()) return false;
58
+ const signal = HOUSING_LANGUAGE_SIGNALS[language];
59
+ if (!signal) return false;
60
+ // Uzbek/Kazakh Cyrillic text reuses Slavic housing words but is neither.
61
+ if (SLAVIC_LANGUAGES.has(language) && NON_SLAVIC_CYRILLIC.test(text)) return false;
62
+ return signalCount(text, language) >= signal.minimum;
63
+ }
64
+
65
+ /**
66
+ * Best-guess language of a housing text, or null when no language reaches its
67
+ * evidence threshold.
68
+ */
69
+ export function detectHousingTextLanguage(value) {
70
+ const text = normalizeUnicode(value ?? '').toLocaleLowerCase();
71
+ if (!text.trim()) return null;
72
+
73
+ let best = null;
74
+ for (const language of Object.keys(HOUSING_LANGUAGE_SIGNALS)) {
75
+ if (SLAVIC_LANGUAGES.has(language) && NON_SLAVIC_CYRILLIC.test(text)) continue;
76
+ const count = signalCount(text, language);
77
+ if (count < HOUSING_LANGUAGE_SIGNALS[language].minimum) continue;
78
+ if (!best || count > best.count) best = { language, count };
79
+ }
80
+ return best?.language ?? null;
81
+ }
@@ -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>>;
@@ -10,7 +10,10 @@ import {
10
10
  import { maskPhoneLikeSpans } from './contact.js';
11
11
 
12
12
  const PRICE_KEYWORD = '(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|аренд(?:а|ная\\s+плата)?|rent)';
13
- const PRICE_CURRENCY = `(?:${moneyCurrencyPattern()})`;
13
+ // moneyCurrencyPattern() includes short codes (cad, ron, aed...) with no
14
+ // boundary of its own, so "100 cadastru" would otherwise read "cad" off an
15
+ // unrelated word as the Canadian dollar.
16
+ const PRICE_CURRENCY = `(?:(?<![\\p{L}\\p{N}_])(?:${moneyCurrencyPattern()})(?![\\p{L}\\p{N}_]))`;
14
17
 
15
18
  function escapeRegex(value) {
16
19
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -51,8 +54,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
51
54
 
52
55
  if (price == null) {
53
56
  let tagged = null;
54
- const reNumSym = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*${PRICE_CURRENCY}`, 'ig');
55
- const reSymNum = new RegExp(`${PRICE_CURRENCY}\\s*(${MONEY_NUMBER_PATTERN})`, 'ig');
57
+ // 'u' is required for PRICE_CURRENCY's \p{L}/\p{N} boundary escapes to work
58
+ // as Unicode property classes — without it they silently match nothing,
59
+ // which had made the boundary guard a no-op.
60
+ const reNumSym = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*${PRICE_CURRENCY}`, 'igu');
61
+ const reSymNum = new RegExp(`${PRICE_CURRENCY}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
56
62
  for (const regex of [reNumSym, reSymNum]) {
57
63
  let match;
58
64
  while ((match = regex.exec(text)) !== null) {
@@ -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,149 @@
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
+ // "не" can attach to either side of the demand — "не нужна одна девушка" and
11
+ // "одна девушка не нужна" both negate it — so both the count word and the
12
+ // verb carry their own guard. Scoped tightly to right before each token
13
+ // rather than a whole-text negation scan, so an unrelated "не" elsewhere in
14
+ // the clause (e.g. "девушку, не курящую" — a non-smoking woman) does not
15
+ // suppress a real demand.
16
+ const NOT_NEGATED_RU = String.raw`(?<!не\s{0,3})`;
17
+
18
+ // `\b` is ASCII-only in JS even under the `u` flag — it never fires around
19
+ // Cyrillic text, so a lookahead built on `\b` after "эмас" silently never
20
+ // matches and the guard becomes a no-op. Use the same explicit
21
+ // letter/digit/underscore boundary the rest of the lexicon relies on.
22
+ const WORD_END = String.raw`(?=$|[^\p{L}\p{N}_])`;
23
+ const NOT_NEGATED_UZ = String.raw`(?!\s*emas${WORD_END})`;
24
+ const NOT_NEGATED_UZ_CYRL = String.raw`(?!\s*эмас${WORD_END})`;
25
+
26
+ const NOT_NEGATED_RO = String.raw`(?<!nu\s{0,3})`;
27
+ const NOT_NEGATED_KK = String.raw`(?!\s*емес${WORD_END})`;
28
+
29
+ // Romanian places the "single" adjective on either side of the noun — "o
30
+ // singură fată" and "o fată singură" are both natural — so the count phrase
31
+ // covers both orders rather than picking one.
32
+ const RO_FEMALE_NOUN = String.raw`(?:fat[aă]\p{L}*|fete\p{L}*|femei\p{L}*)`;
33
+ const RO_COUNT_NOUN = String.raw`(?:o\s+singur[aă]\s+${RO_FEMALE_NOUN}|o\s+${RO_FEMALE_NOUN}\s+singur[aă]|1\s+${RO_FEMALE_NOUN})`;
34
+
35
+ // A point guard right before the verb only blocks *that* instance of the verb
36
+ // from matching — it does not stop the engine from skipping past a negated
37
+ // "не нужна" to a second, unnegated verb word later in the same gap (e.g.
38
+ // "не нужна, ищем" would otherwise still complete via "ищем"). Building the
39
+ // gap out of "not the start of a standalone negation word" repeated
40
+ // character-by-character keeps any negation out of the whole span, not just
41
+ // its own position. `emas`/`эмас`/`емес` trail their verb rather than lead
42
+ // it ("kerak emas"), so those languages' gaps guard the same way as their
43
+ // point guard — excluding the negation word wherever it falls in the gap.
44
+ const gapExcluding = (word, max) => `(?:(?!${word}${WORD_END})${CLAUSE}){0,${max}}`;
45
+ const GAP_NO_NEGATION_RU = (max) => gapExcluding('не', max);
46
+ const GAP_NO_NEGATION_RO = (max) => gapExcluding('nu', max);
47
+ const GAP_NO_NEGATION_UZ = (max) => gapExcluding('emas', max);
48
+ const GAP_NO_NEGATION_UZ_CYRL = (max) => gapExcluding('эмас', max);
49
+ const GAP_NO_NEGATION_KK = (max) => gapExcluding('емес', max);
50
+
51
+ /**
52
+ * Wording that seeks exactly one female tenant, as opposed to the generic
53
+ * "women only" audience wording that ordinary women-only listings use.
54
+ * The count matters: a landlord addressing one specific woman is the signal,
55
+ * not a flat that happens to prefer female tenants.
56
+ */
57
+ const SINGLE_FEMALE_TENANT_PATTERNS = Object.freeze([
58
+ // ru: "только одна девушка", "нужна 1 девушка", "ищу одну женщину", "подселю одну девушку"
59
+ new RegExp(
60
+ NOT_NEGATED_RU
61
+ + String.raw`(?:только|лише|нужн\p{L}*|потрібн\p{L}*|ищ[еуy]\p{L}*|шука\p{L}*|подсел\p{L}*|підсел\p{L}*)`
62
+ + GAP_NO_NEGATION_RU(24)
63
+ + NOT_NEGATED_RU
64
+ + String.raw`[^\p{L}\p{N}_](?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`,
65
+ 'iu',
66
+ ),
67
+ // uk/ru reversed order: "одна девушка нужна"
68
+ new RegExp(
69
+ NOT_NEGATED_RU
70
+ + String.raw`(?:^|[^\p{L}\p{N}_])(?:одн(?:а|ої|ой|у)|1)\s+(?:девушк\p{L}*|дівчин\p{L}*|женщин\p{L}*|жінк\p{L}*)`
71
+ + GAP_NO_NEGATION_RU(18)
72
+ + NOT_NEGATED_RU
73
+ + String.raw`(?:нужн\p{L}*|потрібн\p{L}*|треба|ищ[еуy]\p{L}*|шука\p{L}*)`,
74
+ 'iu',
75
+ ),
76
+ // ro: "doar o singură fată", "am nevoie de 1 fată", "caut o fată singură"
77
+ new RegExp(
78
+ NOT_NEGATED_RO
79
+ + String.raw`(?:doar|caut\p{L}*|căut\p{L}*|trebuie|nevoie\p{L}*)`
80
+ + GAP_NO_NEGATION_RO(24)
81
+ + NOT_NEGATED_RO
82
+ + `[^\\p{L}\\p{N}_]${RO_COUNT_NOUN}`,
83
+ 'iu',
84
+ ),
85
+ // ro reversed order: "o singură fată e nevoie"
86
+ new RegExp(
87
+ NOT_NEGATED_RO
88
+ + `(?:^|[^\\p{L}\\p{N}_])${RO_COUNT_NOUN}`
89
+ + GAP_NO_NEGATION_RO(18)
90
+ + NOT_NEGATED_RO
91
+ + String.raw`(?:doar|trebuie|nevoie\p{L}*|caut[aă]\p{L}*)`,
92
+ 'iu',
93
+ ),
94
+ // uzLatn: "faqat 1 ta qiz kerak", "bitta ayol ijarachi kerak"
95
+ new RegExp(
96
+ String.raw`(?:faqat\s+)?(?:^|[^\p{L}\p{N}_])(?:1|bitta)\s*(?:ta\s*)?(?:qiz|ayol)`
97
+ + GAP_NO_NEGATION_UZ(18)
98
+ + String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)`
99
+ + NOT_NEGATED_UZ,
100
+ 'iu',
101
+ ),
102
+ // uzCyrl: "фақат 1 та қиз керак"
103
+ new RegExp(
104
+ String.raw`(?:фақат\s+)?(?:^|[^\p{L}\p{N}_])(?:1|битта)\s*(?:та\s*)?(?:қиз|аёл)`
105
+ + GAP_NO_NEGATION_UZ_CYRL(18)
106
+ + String.raw`(?:ижарачи\s*)?(?:керак|керакли)`
107
+ + NOT_NEGATED_UZ_CYRL,
108
+ 'iu',
109
+ ),
110
+ // kk: "тек бір қыз керек", "бір қызға орын керек" — Kazakh is verb-final,
111
+ // so unlike the Slavic/Romance pairs above only this one order is natural.
112
+ new RegExp(
113
+ String.raw`(?:тек\s+)?(?:^|[^\p{L}\p{N}_])(?:бір|1)\s+(?:қыз|әйел)\p{L}*`
114
+ + GAP_NO_NEGATION_KK(18)
115
+ + String.raw`(?:керек|қажет|ізде)\p{L}*`
116
+ + NOT_NEGATED_KK,
117
+ 'iu',
118
+ ),
119
+ ]);
120
+
121
+ /**
122
+ * True when the text asks for exactly one female tenant.
123
+ *
124
+ * Deliberately narrower than the `women` audience: "только для девушек" is an
125
+ * ordinary preference and must not match, while "нужна одна девушка" does.
126
+ */
127
+ export function seeksSingleFemaleTenant(value) {
128
+ const text = normalizeUnicode(value ?? '');
129
+ if (!text.trim()) return false;
130
+ return SINGLE_FEMALE_TENANT_PATTERNS.some((pattern) => pattern.test(text));
131
+ }
132
+
133
+ /**
134
+ * Linguistic safety signals for a housing listing.
135
+ *
136
+ * This reports only what the wording says. Whether a given combination is
137
+ * treated as a risk — and any price policy applied on top — belongs to the
138
+ * consuming service, not to the lexicon.
139
+ */
140
+ export function parseHousingSafetySignals(value) {
141
+ const text = normalizeUnicode(value ?? '');
142
+ if (!text.trim()) {
143
+ return deepFreeze({ roomOnly: false, singleFemaleTenantSought: false });
144
+ }
145
+ return deepFreeze({
146
+ roomOnly: isRoomOnlyHousing(text),
147
+ singleFemaleTenantSought: seeksSingleFemaleTenant(text),
148
+ });
149
+ }
@@ -114,7 +114,9 @@ export function parseHousingFloorFromText(value) {
114
114
  if (valid(floor, total)) return { floor, totalFloors: total };
115
115
  }
116
116
 
117
- const notLetter = '(?!н|ей|ів|ност|ка|ки|s)';
117
+ // "li" excludes Uzbek "N qavatli" (an N-storey building), which states the
118
+ // building's total floor count, not which floor this unit is on.
119
+ const notLetter = '(?!н|ей|ів|ност|ка|ки|s|li)';
118
120
  const single = t.match(new RegExp(`(\\d{1,2})[^\\S\\r\\n]*-?[^\\S\\r\\n]*(?:го|ом|ым|ой|ий|nd|rd|th|st|й|м|е)?[^\\S\\r\\n]*${floorWord}${notLetter}`)) || t.match(new RegExp(`${floorWord}\\s*[:№#]?\\s*(\\d{1,2})\\b`));
119
121
  if (single) {
120
122
  const floor = Number(single[1]);
@@ -132,6 +134,17 @@ export function parseHousingFloorFromText(value) {
132
134
  const total = Number(bare[2]);
133
135
  if (floor >= 1 && floor <= 40 && total >= 2 && total <= 40 && floor <= total) return { floor, totalFloors: total };
134
136
  }
137
+
138
+ // A building's total floor count ("8 qavatli uy", "этажность: 9") is still
139
+ // worth reporting even when no unit floor is stated at all.
140
+ const totalOnly =
141
+ t.match(/(?:этажность|этажей|поверхови|поверховість|qavatlar(?:\s*soni)?|qavatli|қабатты?)\D{0,6}(\d{1,2})/) ||
142
+ t.match(/([1-9]\d?)\s*-?\s*(?:этажн[а-яё]*|поверхов[а-яіїґ]*|qavatli|қабатты?)\s*(?:дом|здани|будин|uy|bino)?/i);
143
+ if (totalOnly) {
144
+ const total = Number(totalOnly[1]);
145
+ if (total >= 1 && total <= 200) return { floor: null, totalFloors: total };
146
+ }
147
+
135
148
  return { floor: null, totalFloors: null };
136
149
  }
137
150
 
@@ -0,0 +1,2 @@
1
+ export function isGenericHousingTitle(value: unknown): boolean;
2
+ export function hasMeaningfulHousingTitle(value: unknown): boolean;
@@ -0,0 +1,84 @@
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
+ // ro: "închiriere pe termen lung/scurt", "apartamente de vânzare" (subject
19
+ // leads here, so this alt embeds its own subject like the English "for
20
+ // rent/sale" form above), bare "închiriere"/"chirie"/"vânzare"
21
+ + String.raw`|(?:închiri\p{L}*|inchiri\p{L}*)\s+pe\s+termen\s+(?:lung|scurt)`
22
+ + String.raw`|(?:apartament\p{L}*|cas[aă]\p{L}*|case\p{L}*)\s+de\s+(?:vânzare|vanzare|închiriat|inchiriat)`
23
+ + String.raw`|închiri\p{L}*|inchiri\p{L}*|chirie\p{L}*|vânz\p{L}*|vanz\p{L}*`
24
+ // kk: "жалдау"/"жалға"/"жалға беру" (long rent), "ұзақ мерзімге жалдау"
25
+ // (long rent, spelled out), "тәуліктік жалдау" (short rent), "сату"/"сатылым"
26
+ + String.raw`|ұзақ\s+мерзімге\s+жалд\p{L}*|тәулік\p{L}*\s+жалд\p{L}*`
27
+ + String.raw`|жалд\p{L}*|жалғ\p{L}*(?:\s+беру\p{L}*)?|сат\p{L}*`
28
+ + String.raw`)`;
29
+
30
+ const SUBJECT = String.raw`(?:`
31
+ + String.raw`квартир\p{L}*|кімнат\p{L}*|комнат\p{L}*|будинк\p{L}*|будинків|дом\p{L}*|житл\p{L}*|нерухомост\p{L}*|недвижимост\p{L}*`
32
+ + String.raw`|apartments?|flats?|houses?|rooms?|property|real\s+estate`
33
+ + String.raw`|kvartira\p{L}*|uylar|xona\p{L}*`
34
+ + String.raw`|apartament\p{L}*|cameră\p{L}*|camera\p{L}*|cas[aă]\p{L}*|case\p{L}*|locuinț\p{L}*|locuinta\p{L}*`
35
+ + String.raw`|пәтер\p{L}*|бөлме\p{L}*|үй\p{L}*`
36
+ + String.raw`)`;
37
+
38
+ // Trailing locality clauses, comma- or dash-separated, such as
39
+ // ", Подільський район, біля станції метро". Each clause is capped in length
40
+ // so this stays a location tail rather than swallowing real content — but a
41
+ // digit anywhere still wins first via SPECIFIC_DETAIL, which is the actual
42
+ // guard against misreading a real title as generic.
43
+ const LOCALITY_TAIL = String.raw`(?:\s*[,–—-]\s*[^\r\n,]{1,40}){0,3}`;
44
+
45
+ const GENERIC_TITLE_PATTERN = new RegExp(
46
+ `^\\s*${CATEGORY}(?:\\s+${SUBJECT})?${LOCALITY_TAIL}\\s*$`,
47
+ 'iu',
48
+ );
49
+
50
+ // Digits are the cheapest proof a title says something specific: a room count,
51
+ // an area, a price or a street number. "2-к квартира, 54 м²" is a real title
52
+ // even though it opens with the same words as the category heading.
53
+ const SPECIFIC_DETAIL = /\d/u;
54
+
55
+ function titleContent(value) {
56
+ return normalizeUnicode(value ?? '')
57
+ .replace(
58
+ /[\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Modifier}\p{Variation_Selector}\p{Join_Control}]/gu,
59
+ '',
60
+ )
61
+ .replace(/[^\p{L}\p{N}]+/gu, '');
62
+ }
63
+
64
+ /**
65
+ * True when the title is a marketplace category heading rather than a title
66
+ * describing this specific property.
67
+ */
68
+ export function isGenericHousingTitle(value) {
69
+ const text = normalizeUnicode(value ?? '').replace(/\s+/g, ' ').trim();
70
+ if (!text) return false;
71
+ if (SPECIFIC_DETAIL.test(text)) return false;
72
+ return GENERIC_TITLE_PATTERN.test(text);
73
+ }
74
+
75
+ /**
76
+ * True when the title can stand on its own in a listing card or popup.
77
+ *
78
+ * Rejects titles carrying almost no letters or digits (emoji- or
79
+ * punctuation-only) as well as marketplace category headings.
80
+ */
81
+ export function hasMeaningfulHousingTitle(value) {
82
+ if (titleContent(value).length < 3) return false;
83
+ return !isGenericHousingTitle(value);
84
+ }
package/src/housing.js CHANGED
@@ -219,7 +219,10 @@ export function resolveHousingPropertyType(value) {
219
219
  const flat = PROPERTY_TYPES.find((entry) => entry.canonical === 'flat');
220
220
  if (flat && findCanonical(text, [flat], { partial: true })) return 'flat';
221
221
  const genericUzbekHome = /(?:^|[^\p{L}\p{N}_])(?:uy|уй)(?=$|[^\p{L}\p{N}_])/iu.test(text);
222
- const explicitHouse = /(?:hovli|xovli|ҳовли|ховли|house|casa|dom|villa|будин|коттедж|вілл|вилл|(?:^|[^\p{L}\p{N}_])(?:дом|үй)(?=$|[^\p{L}\p{N}_]))/iu.test(text);
222
+ // "dom" is only 3 letters and matches as a bare substring of unrelated
223
+ // English words (e.g. "seldom", "random"), so it needs the same token
224
+ // boundary already applied to its Cyrillic counterpart "дом" below.
225
+ const explicitHouse = /(?:hovli|xovli|ҳовли|ховли|house|casa|villa|будин|коттедж|вілл|вилл|(?:^|[^\p{L}\p{N}_])(?:дом|үй|dom)(?=$|[^\p{L}\p{N}_]))/iu.test(text);
223
226
  if (genericUzbekHome && !explicitHouse) return null;
224
227
  return findCanonical(text, PROPERTY_TYPES, { partial: true })?.canonical || null;
225
228
  }
package/src/index.js CHANGED
@@ -36,10 +36,14 @@ 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';
42
45
  export * from './hiring-context.js';
46
+ export { parseHiringContext } from './hiring-context-compat.js';
43
47
  export * from './money.js';
44
48
  export * from './hiring-professions.js';
45
49
  export * from './hiring-advanced.js';
package/src/money-core.js CHANGED
@@ -5,17 +5,51 @@ import {
5
5
  NUMBER_MULTIPLIERS,
6
6
  } from './money-lexicon.js';
7
7
 
8
- export const MONEY_NUMBER_PATTERN = '\\d{1,3}(?:[ \\u00a0.,]\\d{3})+|\\d+(?:[.,]\\d+)?';
8
+ // Monetary values in job descriptions commonly combine thousands grouping with
9
+ // decimals (e.g. 137,000.00 or 137.000,00). Keep the grouped variants ahead of
10
+ // the generic decimal form so a range parser consumes the complete endpoint.
11
+ export const MONEY_NUMBER_PATTERN = '(?:\\d{1,3}(?:[ \\u00a0]\\d{3})+(?:[.,]\\d+)?|\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?|\\d+(?:[.,]\\d+)?)';
9
12
  export const MONEY_SCALE_PATTERN = 'k|к|тыс\\.?|тысяч(?:а|и)?|тис\\.?|thousand|ming|мың|m|м|млн\\.?|mln|million|миллион(?:ов)?|мільйон(?:ів)?|bn|млрд|mlrd|billion';
10
- export const MONEY_RANGE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?\\s*(?:-|–|—|до|to|bis|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?`, 'iu');
13
+ // Each scale group needs the token-boundary guard MONEY_SINGLE_RE already has
14
+ // below: without it, "2 до 3 месяцев" reads "м" off "месяцев" as the million
15
+ // abbreviation and turns 3 into 3,000,000. The boundary is nested inside the
16
+ // optional group (rather than placed after it) so a scale match that fails
17
+ // the boundary check simply falls back to "no scale" instead of failing the
18
+ // whole alternative — otherwise a no-space separator like "5до10" (no scale
19
+ // present at all) would stop matching, since "до" doesn't satisfy the
20
+ // boundary either.
21
+ export const MONEY_RANGE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(?:(${MONEY_SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?\\s*(?:-|–|—|до|to|bis|dan\\s+gacha)\\s*(${MONEY_NUMBER_PATTERN})\\s*(?:(${MONEY_SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`, 'iu');
11
22
  export const MONEY_SINGLE_RE = new RegExp(`(${MONEY_NUMBER_PATTERN})\\s*(${MONEY_SCALE_PATTERN})?(?=$|[^\\p{L}\\p{N}_])`, 'giu');
12
23
 
13
24
  export function parseNumericAmount(raw) {
14
25
  let value = String(raw || '').replace(/\u00a0/g, ' ').trim();
15
26
  if (!value) return null;
16
- const grouped = /\d[ ,.]\d{3}(?:[ ,.]\d{3})*/.test(value);
17
- if (grouped) value = value.replace(/[ ,.]/g, '');
18
- else value = value.replace(/\s+/g, '').replace(',', '.');
27
+
28
+ // Spaces are unambiguous thousands separators in supported salary formats.
29
+ value = value.replace(/\s+/g, '');
30
+
31
+ const lastComma = value.lastIndexOf(',');
32
+ const lastDot = value.lastIndexOf('.');
33
+ if (lastComma >= 0 && lastDot >= 0) {
34
+ // When both separators are present, the final separator is decimal and the
35
+ // other one is grouping: 137,000.00 / 137.000,00.
36
+ const decimal = lastComma > lastDot ? ',' : '.';
37
+ const grouping = decimal === ',' ? /\./g : /,/g;
38
+ value = value.replace(grouping, '');
39
+ if (decimal === ',') value = value.replace(',', '.');
40
+ } else {
41
+ const separator = lastComma >= 0 ? ',' : lastDot >= 0 ? '.' : null;
42
+ if (separator) {
43
+ const escaped = separator === '.' ? '\\.' : ',';
44
+ const groupingRe = new RegExp(`^\\d{1,3}(?:${escaped}\\d{3})+$`);
45
+ if (groupingRe.test(value)) {
46
+ value = value.split(separator).join('');
47
+ } else if (separator === ',') {
48
+ value = value.replace(',', '.');
49
+ }
50
+ }
51
+ }
52
+
19
53
  const parsed = Number(value);
20
54
  return Number.isFinite(parsed) ? parsed : null;
21
55
  }
package/src/money.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { findPhoneLikeSpans } from './contact.js';
2
2
  import { aliasesOf, findCanonical, normalizeUnicode } from './normalization.js';
3
3
  import {
4
+ CURRENCY_SYMBOL_CANDIDATES,
4
5
  CURRENCY_TERMS,
5
6
  NUMBER_MULTIPLIERS,
6
7
  SALARY_MODIFIERS,
@@ -26,13 +27,22 @@ export {
26
27
  parseScaledAmount,
27
28
  } from './money-core.js';
28
29
 
29
- const CONTACT_MARKER_RE = /(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
30
+ // A leading boundary keeps "тел"/"phone" etc. from matching as a suffix of an
31
+ // unrelated word ("хостел", "котел", "telegraph"), which would wrongly mark a
32
+ // following phone-like number as a protected contact span.
33
+ const CONTACT_MARKER_RE = /(?<![\p{L}\p{N}_])(?:телефон|тел\.?|phone|mobile|mob\.?|whatsapp|viber|telegram|контакт|contact|aloqa|murojaat|bog(?:['’ʻʼ‘`])?lanish)\s*[::—-]?\s*$/iu;
34
+ const JOBS_I18N_PERIOD_RE = /\bjobs\.per(hour|day|shift|week|month|year|project|piece)\b/iu;
30
35
 
31
36
  function hasSalaryContext(text) {
32
37
  return /(?:salary|зарплат|з\s*п\b|оплат|ставк|доход|оклад|компенсац|maosh|oylik|ish\s+haqi|жалақы|айлық|еңбекақы|salariu|оплата)/iu.test(text);
33
38
  }
34
39
 
35
40
  function periodFromText(text) {
41
+ // Some vacancy sources leak untranslated i18n keys into salary strings,
42
+ // e.g. "$208K/jobs.perWeek". Treat those markers as first-class periods
43
+ // instead of letting consumers fall back to an incorrect monthly salary.
44
+ const jobsMarker = String(text || '').match(JOBS_I18N_PERIOD_RE);
45
+ if (jobsMarker?.[1]) return jobsMarker[1].toLowerCase();
36
46
  return findCanonical(text, SALARY_PERIODS, { partial: true })?.canonical || null;
37
47
  }
38
48
 
@@ -61,10 +71,23 @@ function moneyContextScore(text, start, end, scaled = false) {
61
71
  return score;
62
72
  }
63
73
 
74
+ function rangeSearchText(text) {
75
+ // MONEY_RANGE_RE intentionally parses numeric structure only. Currency symbols
76
+ // may legally repeat around both endpoints ("$55 — $65"); blank them with
77
+ // equal-length whitespace so the range parser can see the numbers while all
78
+ // original indices still line up with phone protection/context scoring.
79
+ let normalized = text;
80
+ for (const symbol of Object.keys(CURRENCY_SYMBOL_CANDIDATES)) {
81
+ normalized = normalized.split(symbol).join(' '.repeat(symbol.length));
82
+ }
83
+ return normalized;
84
+ }
85
+
64
86
  function bestRange(text, protectedSpans) {
65
87
  const ranges = new RegExp(MONEY_RANGE_RE.source, 'giu');
66
88
  const candidates = [];
67
- for (const match of text.matchAll(ranges)) {
89
+ const searchable = rangeSearchText(text);
90
+ for (const match of searchable.matchAll(ranges)) {
68
91
  const start = match.index ?? 0;
69
92
  const end = start + match[0].length;
70
93
  if (overlapsProtectedPhone(start, end, protectedSpans)) continue;
@@ -124,7 +124,11 @@ const DISTRICT_CONTEXT_ALIASES = Object.freeze({
124
124
 
125
125
  const NUMBERED_CONTEXT = '(?:tumani|тумани|district|район|massiv|массив)';
126
126
  const NUMBERED_SUFFIX_CONTEXT = '(?:chi|чи|й|квартал|kvartal|hudud(?:da)?|худуд(?:да)?)';
127
- const DISTRICT_MARKER = '(?:район|туман\\p{L}*|tumani|district)';
127
+ // The open \p{L}* suffix used to swallow any trailing letters, so
128
+ // "туманность" (an unrelated word) satisfied this as if "туман" (district)
129
+ // had been mentioned. {0,4} covers real case endings (туманда, туманидан)
130
+ // while excluding unrelated 5+-letter continuations.
131
+ const DISTRICT_MARKER = '(?:район|туман\\p{L}{0,4}|tumani|district)';
128
132
 
129
133
  function normalizedAlternatives(values) {
130
134
  return values