@whiteslove/parsing-lexicon 0.2.7 → 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/package.json +1 -1
- package/src/contact.js +3 -1
- package/src/hiring-context-compat.js +22 -0
- package/src/hiring-semantics.js +4 -1
- package/src/hiring-source-semantics.js +6 -1
- package/src/housing-language.d.ts +1 -1
- package/src/housing-language.js +9 -1
- package/src/housing-money.js +9 -3
- package/src/housing-safety.js +83 -9
- package/src/housing-text.js +14 -1
- package/src/housing-title.js +18 -4
- package/src/housing.js +4 -1
- package/src/index.js +1 -0
- package/src/money-core.js +39 -5
- package/src/money.js +25 -2
- package/src/tashkent-housing-geography.js +5 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
+
}
|
package/src/hiring-semantics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 || ''));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HousingTextLanguage = 'ru' | 'uk' | 'en' | 'uz';
|
|
1
|
+
export type HousingTextLanguage = 'ru' | 'uk' | 'en' | 'uz' | 'ro' | 'kk';
|
|
2
2
|
|
|
3
3
|
export function housingTextIsInLanguage(value: unknown, language: string): boolean;
|
|
4
4
|
export function detectHousingTextLanguage(value: unknown): HousingTextLanguage | null;
|
package/src/housing-language.js
CHANGED
|
@@ -18,7 +18,7 @@ const HOUSING_LANGUAGE_SIGNALS = Object.freeze({
|
|
|
18
18
|
minimum: 2,
|
|
19
19
|
},
|
|
20
20
|
uk: {
|
|
21
|
-
pattern: /(?:квартир\p{L}*|кімнат\p{L}*|поверх\p{L}*|будинк\p{L}*|цін\p{L}*|здається|продається|оренд\p{L}*|поруч|метро|сімейн\p{L}*|комунальн\p{L}*|ремонт\p{L}
|
|
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
22
|
minimum: 2,
|
|
23
23
|
},
|
|
24
24
|
en: {
|
|
@@ -29,6 +29,14 @@ const HOUSING_LANGUAGE_SIGNALS = Object.freeze({
|
|
|
29
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
30
|
minimum: 2,
|
|
31
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
|
+
},
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
function signalCount(text, language) {
|
package/src/housing-money.js
CHANGED
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
import { maskPhoneLikeSpans } from './contact.js';
|
|
11
11
|
|
|
12
12
|
const PRICE_KEYWORD = '(?:цена|ціна|нарх(?:и)?|narx|price|стоимост[ьи]|аренд(?:а|ная\\s+плата)?|rent)';
|
|
13
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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) {
|
package/src/housing-safety.js
CHANGED
|
@@ -7,6 +7,47 @@ import { isRoomOnlyHousing } from './housing-source-aliases.js';
|
|
|
7
7
|
// demand for one woman, which it is not.
|
|
8
8
|
const CLAUSE = String.raw`[^\r\n.!?]`;
|
|
9
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
|
+
|
|
10
51
|
/**
|
|
11
52
|
* Wording that seeks exactly one female tenant, as opposed to the generic
|
|
12
53
|
* "women only" audience wording that ordinary women-only listings use.
|
|
@@ -16,30 +57,63 @@ const CLAUSE = String.raw`[^\r\n.!?]`;
|
|
|
16
57
|
const SINGLE_FEMALE_TENANT_PATTERNS = Object.freeze([
|
|
17
58
|
// ru: "только одна девушка", "нужна 1 девушка", "ищу одну женщину", "подселю одну девушку"
|
|
18
59
|
new RegExp(
|
|
19
|
-
|
|
20
|
-
+
|
|
21
|
-
+
|
|
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}*)`,
|
|
22
65
|
'iu',
|
|
23
66
|
),
|
|
24
67
|
// uk/ru reversed order: "одна девушка нужна"
|
|
25
68
|
new RegExp(
|
|
26
|
-
|
|
27
|
-
+
|
|
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
|
|
28
73
|
+ String.raw`(?:нужн\p{L}*|потрібн\p{L}*|треба|ищ[еуy]\p{L}*|шука\p{L}*)`,
|
|
29
74
|
'iu',
|
|
30
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
|
+
),
|
|
31
94
|
// uzLatn: "faqat 1 ta qiz kerak", "bitta ayol ijarachi kerak"
|
|
32
95
|
new RegExp(
|
|
33
96
|
String.raw`(?:faqat\s+)?(?:^|[^\p{L}\p{N}_])(?:1|bitta)\s*(?:ta\s*)?(?:qiz|ayol)`
|
|
34
|
-
+
|
|
35
|
-
+ String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)
|
|
97
|
+
+ GAP_NO_NEGATION_UZ(18)
|
|
98
|
+
+ String.raw`(?:ijarachi\s*)?(?:kerak|kere|kerakli)`
|
|
99
|
+
+ NOT_NEGATED_UZ,
|
|
36
100
|
'iu',
|
|
37
101
|
),
|
|
38
102
|
// uzCyrl: "фақат 1 та қиз керак"
|
|
39
103
|
new RegExp(
|
|
40
104
|
String.raw`(?:фақат\s+)?(?:^|[^\p{L}\p{N}_])(?:1|битта)\s*(?:та\s*)?(?:қиз|аёл)`
|
|
41
|
-
+
|
|
42
|
-
+ String.raw`(?:ижарачи\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,
|
|
43
117
|
'iu',
|
|
44
118
|
),
|
|
45
119
|
]);
|
package/src/housing-text.js
CHANGED
|
@@ -114,7 +114,9 @@ export function parseHousingFloorFromText(value) {
|
|
|
114
114
|
if (valid(floor, total)) return { floor, totalFloors: total };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
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
|
|
package/src/housing-title.js
CHANGED
|
@@ -15,18 +15,32 @@ const CATEGORY = String.raw`(?:`
|
|
|
15
15
|
+ String.raw`|(?:apartments?|flats?|houses?|rooms?)\s+for\s+(?:rent|sale)`
|
|
16
16
|
+ String.raw`|rentals?|daily\s+rentals?`
|
|
17
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}*`
|
|
18
28
|
+ String.raw`)`;
|
|
19
29
|
|
|
20
30
|
const SUBJECT = String.raw`(?:`
|
|
21
31
|
+ String.raw`квартир\p{L}*|кімнат\p{L}*|комнат\p{L}*|будинк\p{L}*|будинків|дом\p{L}*|житл\p{L}*|нерухомост\p{L}*|недвижимост\p{L}*`
|
|
22
32
|
+ String.raw`|apartments?|flats?|houses?|rooms?|property|real\s+estate`
|
|
23
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}*`
|
|
24
36
|
+ String.raw`)`;
|
|
25
37
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
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}`;
|
|
30
44
|
|
|
31
45
|
const GENERIC_TITLE_PATTERN = new RegExp(
|
|
32
46
|
`^\\s*${CATEGORY}(?:\\s+${SUBJECT})?${LOCALITY_TAIL}\\s*$`,
|
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
|
-
|
|
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
|
@@ -43,6 +43,7 @@ export * from './housing-structured.js';
|
|
|
43
43
|
export * from './hiring.js';
|
|
44
44
|
export * from './hiring-languages.js';
|
|
45
45
|
export * from './hiring-context.js';
|
|
46
|
+
export { parseHiringContext } from './hiring-context-compat.js';
|
|
46
47
|
export * from './money.js';
|
|
47
48
|
export * from './hiring-professions.js';
|
|
48
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|