@whiteslove/parsing-lexicon 0.5.11 → 0.5.13
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 +2 -2
- package/src/hiring-advanced.js +2 -0
- package/src/hiring-context.js +1 -1
- package/src/hiring-source-semantics.d.ts +7 -0
- package/src/hiring-source-semantics.js +33 -0
- package/src/housing-listing-fields.js +23 -4
- package/src/housing-money.d.ts +2 -1
- package/src/housing-money.js +133 -31
- package/src/housing-structured.js +9 -5
- package/src/money.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whiteslove/parsing-lexicon",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.13",
|
|
4
4
|
"description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -220,4 +220,4 @@
|
|
|
220
220
|
"geography"
|
|
221
221
|
],
|
|
222
222
|
"license": "MIT"
|
|
223
|
-
}
|
|
223
|
+
}
|
package/src/hiring-advanced.js
CHANGED
|
@@ -130,6 +130,7 @@ export function parseExperience(value) {
|
|
|
130
130
|
|
|
131
131
|
const range = text.match(RANGE_RE);
|
|
132
132
|
if (range) {
|
|
133
|
+
if (/(?:возраст|вік|age|yosh|ёш|vârsta|varsta)/iu.test(range[0]) && !/(?:опыт|experience|досвід|tajriba|тәжірибе)/iu.test(range[0])) return null;
|
|
133
134
|
const minYears = Number(range[1].replace(',', '.'));
|
|
134
135
|
const maxYears = Number(range[2].replace(',', '.'));
|
|
135
136
|
return Object.freeze({ requirement: modifier || 'required', minYears: Math.min(minYears, maxYears), maxYears: Math.max(minYears, maxYears) });
|
|
@@ -137,6 +138,7 @@ export function parseExperience(value) {
|
|
|
137
138
|
|
|
138
139
|
const single = text.match(MIN_RE);
|
|
139
140
|
if (single) {
|
|
141
|
+
if (/(?:возраст|вік|age|yosh|ёш|vârsta|varsta)/iu.test(single[0]) && !/(?:опыт|experience|досвід|tajriba|тәжірибе)/iu.test(single[0])) return null;
|
|
140
142
|
const years = Number(single[1].replace(',', '.'));
|
|
141
143
|
const isExact = !/(?:от|не\s+менее|minimum|min\.?|at\s+least|від|kamida|кемінде|\+)/iu.test(single[0]);
|
|
142
144
|
return Object.freeze({ requirement: modifier || 'required', minYears: years, maxYears: isExact ? years : null });
|
package/src/hiring-context.js
CHANGED
|
@@ -194,7 +194,7 @@ export const HIRING_SECTION_MARKERS = Object.freeze({
|
|
|
194
194
|
export const HIRING_NON_CONTENT_TERMS = Object.freeze([
|
|
195
195
|
group('vacancyDigest', { ru: ['дайджест вакансий', 'подборка вакансий', 'вакансии недели'], en: ['job digest', 'vacancy digest', 'jobs roundup'], uk: ['добірка вакансій'], ro: ['selecție de joburi'], uzLatn: ['vakansiyalar toplami'], uzCyrl: ['вакансиялар тўплами'], kk: ['вакансиялар топтамасы'] }),
|
|
196
196
|
group('recruitmentAd', { ru: ['поможем подобрать сотрудника', 'услуги рекрутера', 'закроем вакансию'], en: ['recruitment services', 'we find candidates', 'staffing services'], uk: ['послуги рекрутера'], ro: ['servicii de recrutare'], uzLatn: ['rekruting xizmati'], uzCyrl: ['рекрутинг хизмати'], kk: ['рекрутинг қызметтері'] }),
|
|
197
|
-
group('jobService', { ru: ['помогу найти работу', 'подбор вакансий', 'сервис поиска работы'], en: ['job search service', 'we help you find a job'], uk: ['допоможемо знайти роботу'], ro: ['serviciu de căutare job'], uzLatn: ['ish topishga yordam'], uzCyrl: ['иш топишга ёрдам'], kk: ['жұмыс табуға көмектесеміз'] }),
|
|
197
|
+
group('jobService', { ru: ['помогу найти работу', 'подбор вакансий', 'сервис поиска работы', 'каналы с вакансиями', 'выбирай свою сферу и подписывайся'], en: ['job search service', 'we help you find a job'], uk: ['допоможемо знайти роботу'], ro: ['serviciu de căutare job'], uzLatn: ['ish topishga yordam'], uzCyrl: ['иш топишга ёрдам'], kk: ['жұмыс табуға көмектесеміз'] }),
|
|
198
198
|
group('spam', { ru: ['подпишись на канал', 'заработок без вложений', 'доход без вложений', 'легкий заработок'], en: ['subscribe to our channel', 'earn money without investment', 'easy money'], uk: ['підпишись на канал', 'заробіток без вкладень'], ro: ['abonează-te la canal', 'câștig fără investiții'], uzLatn: ['kanalga obuna boling', 'sarmoyasiz daromad'], uzCyrl: ['каналга обуна бўлинг'], kk: ['арнаға жазылыңыз', 'инвестициясыз табыс'] }),
|
|
199
199
|
]);
|
|
200
200
|
|
|
@@ -6,6 +6,13 @@ export type VisaSponsorshipWording = 'offered' | 'notOffered' | null;
|
|
|
6
6
|
export function cleanHiringSourceText(value: unknown): string;
|
|
7
7
|
export function extractCandidateStructuredField(value: unknown, key: string, maxLength?: number): string | null;
|
|
8
8
|
export function extractJobStructuredField(value: unknown, key: string, maxLength?: number): string | null;
|
|
9
|
+
export type HiringVacancyEntry = Readonly<{ title: string; text: string }>;
|
|
10
|
+
export type SplitHiringVacancyPost = Readonly<{
|
|
11
|
+
prefix: string;
|
|
12
|
+
suffix: string;
|
|
13
|
+
entries: readonly HiringVacancyEntry[];
|
|
14
|
+
}>;
|
|
15
|
+
export function splitHiringVacancyEntries(value: unknown): SplitHiringVacancyPost;
|
|
9
16
|
export function extractCandidateDisplayName(value: unknown): string;
|
|
10
17
|
export function extractCandidateExperienceMentions(value: unknown): readonly CandidateExperienceMention[];
|
|
11
18
|
export function parseHiringSourceSalary(value: unknown): SalaryParseResult | null;
|
|
@@ -62,6 +62,39 @@ export function extractJobStructuredField(value, key, maxLength = 220) {
|
|
|
62
62
|
return extractField(value, JOB_FIELD_TERMS[key], FIELD_EXTRA_ALIASES.job[key] || [], maxLength);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const INLINE_VACANCY_HEADING_RE = /✔️?\s*([A-ZА-ЯЁЎҚҒҲІЇЄ][A-ZА-ЯЁЎҚҒҲІЇЄ\s()\/-]{2,100}?)(?=\s*(?:[•⏳✔📍✈📞]|$))/gu;
|
|
66
|
+
const SHARED_HIRING_CONTACT_RE = /(?:✈️?\s*Telegram\s*:|📞\s*Контакт\s*:)/iu;
|
|
67
|
+
|
|
68
|
+
/** Split a multi-vacancy source post into role-local blocks and shared context. */
|
|
69
|
+
export function splitHiringVacancyEntries(value) {
|
|
70
|
+
const text = cleanHiringSourceText(value);
|
|
71
|
+
if (!text) return Object.freeze({ prefix: '', suffix: '', entries: Object.freeze([]) });
|
|
72
|
+
|
|
73
|
+
const headings = [...text.matchAll(INLINE_VACANCY_HEADING_RE)]
|
|
74
|
+
.map((match) => ({ start: match.index ?? 0, title: match[1].replace(/\s+/g, ' ').trim() }));
|
|
75
|
+
if (headings.length < 2) return Object.freeze({ prefix: text, suffix: '', entries: Object.freeze([]) });
|
|
76
|
+
|
|
77
|
+
let suffix = '';
|
|
78
|
+
let contentEnd = text.length;
|
|
79
|
+
const lastStart = headings.at(-1).start;
|
|
80
|
+
const contact = SHARED_HIRING_CONTACT_RE.exec(text.slice(lastStart));
|
|
81
|
+
if (contact?.index != null) {
|
|
82
|
+
contentEnd = lastStart + contact.index;
|
|
83
|
+
suffix = text.slice(contentEnd).trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entries = headings.map((heading, index) => {
|
|
87
|
+
const end = Math.min(headings[index + 1]?.start ?? contentEnd, contentEnd);
|
|
88
|
+
return Object.freeze({ title: heading.title, text: text.slice(heading.start, end).trim() });
|
|
89
|
+
}).filter(({ text: entryText }) => Boolean(entryText));
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
prefix: text.slice(0, headings[0].start).trim(),
|
|
93
|
+
suffix,
|
|
94
|
+
entries: Object.freeze(entries),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
export function extractCandidateDisplayName(value) {
|
|
66
99
|
const text = String(value || '');
|
|
67
100
|
const structured = extractCandidateName(text);
|
|
@@ -21,9 +21,28 @@ function parseBedrooms(text) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function parseBathrooms(text) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const bathroom = '(?:сан\\s?уз(?:е)?л\\p{L}*|с\\/?у(?=$|[^\\p{L}\\p{N}_])|ванн[аы]|bathrooms?|sanuzel|hammom)';
|
|
25
|
+
const areaUnit = '(?:м²|м2|m²|m2|sqm|sq\\.?\\s*m|кв\\.?\\s*м(?:²|2)?)';
|
|
26
|
+
|
|
27
|
+
// Room-area rows such as "Санузел4.52 м²" carry the size of a singular
|
|
28
|
+
// bathroom, not a bathroom count. Resolve these before generic count forms
|
|
29
|
+
// so another nearby number cannot win.
|
|
30
|
+
const singularArea = text.match(new RegExp(
|
|
31
|
+
`(?:сан\\s?уз(?:е)?л|bathroom|sanuzel|hammom)\\s*[:=\\-–—]?\\s*\\d{1,4}(?:[.,]\\d{1,2})?\\s*${areaUnit}(?=$|[^\\p{L}\\p{N}_])`,
|
|
32
|
+
'iu',
|
|
33
|
+
));
|
|
34
|
+
if (singularArea) return 1;
|
|
35
|
+
|
|
36
|
+
const forward = text.match(new RegExp(`(\\d{1,2})\\s*${bathroom}`, 'iu'));
|
|
37
|
+
const forwardCount = number(forward, 1, 10);
|
|
38
|
+
if (forwardCount != null) return forwardCount;
|
|
39
|
+
|
|
40
|
+
const reversed = text.match(new RegExp(
|
|
41
|
+
`${bathroom}\\D{0,4}(\\d{1,2})(?![.,]\\d)(?!\\s*${areaUnit})`,
|
|
42
|
+
'iu',
|
|
43
|
+
));
|
|
44
|
+
const reversedCount = number(reversed, 1, 10);
|
|
45
|
+
return reversedCount;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
function parseBuildingYear(text) {
|
|
@@ -191,7 +210,7 @@ export function parseHousingListingFields(value, { country = '' } = {}) {
|
|
|
191
210
|
/евро[- ]?(?:студи\p{L}*|двушк\p{L}*|трёшк\p{L}*|трешк\p{L}*|четырёшк\p{L}*|четрешк\p{L}*|планировк\p{L}*|[1-9](?!\p{N}))|европланировк\p{L}*|euro[- ]?layout/iu,
|
|
192
211
|
),
|
|
193
212
|
gas,
|
|
194
|
-
newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s
|
|
213
|
+
newBuilding: bool(text, /новостро|новобуд|новый\s+дом|novast(?:royka|iroyka)|navast(?:royka|iroyka)|new\s*build|newly\s+built|yangi\s+(?:bino|qurilgan|uy)|bloc\s+nou/iu),
|
|
195
214
|
communalSeparated: parseCommunalSeparated(text, country),
|
|
196
215
|
parking: bool(
|
|
197
216
|
text,
|
package/src/housing-money.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ export type HousingPriceParseResult = Readonly<{
|
|
|
5
5
|
}>;
|
|
6
6
|
|
|
7
7
|
export function parseHousingPrice(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
|
|
8
|
-
export
|
|
8
|
+
export function parseHousingPricePerSqm(value: unknown, fallbackCurrency?: string): HousingPriceParseResult;
|
|
9
|
+
export const parsePriceFromText: typeof parseHousingPrice;
|
package/src/housing-money.js
CHANGED
|
@@ -30,6 +30,75 @@ function escapeRegex(value) {
|
|
|
30
30
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const SCALE_PATTERN = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
|
|
34
|
+
.sort((a, b) => String(b).length - String(a).length)
|
|
35
|
+
.map(escapeRegex)
|
|
36
|
+
.join('|');
|
|
37
|
+
|
|
38
|
+
const PER_SQM_UNIT = String.raw`(?:m(?:2|²)|м(?:2|²)|м\s*кв\.?|кв\.?\s*м(?:2|²)?|sqm|sq\.?\s*m|square\s+met(?:er|re)s?|квадратн\p{L}*\s+метр\p{L}*)`;
|
|
39
|
+
const PER_SQM_NUMBER = `(${MONEY_NUMBER_PATTERN})(?:\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`;
|
|
40
|
+
const PER_SQM_CURRENCY = `(?:\\s*${CURRENCY_ALT}(?![\\p{L}\\p{N}_]))?`;
|
|
41
|
+
const PER_SQM_AFTER_AMOUNT_RE = new RegExp(
|
|
42
|
+
`${PER_SQM_NUMBER}${PER_SQM_CURRENCY}\\s*(?:за\\s*(?:1\\s*)?|по\\s*|/\\s*|per\\s+)${PER_SQM_UNIT}(?=$|[^\\p{L}\\p{N}_])`,
|
|
43
|
+
'igu',
|
|
44
|
+
);
|
|
45
|
+
const PER_SQM_AFTER_UNIT_RE = new RegExp(
|
|
46
|
+
`${PER_SQM_NUMBER}${PER_SQM_CURRENCY}\\s*${PER_SQM_UNIT}\\s*(?:uchun|учун|ga|га)(?=$|[^\\p{L}\\p{N}_])`,
|
|
47
|
+
'igu',
|
|
48
|
+
);
|
|
49
|
+
const PER_SQM_BEFORE_AMOUNT_RE = new RegExp(
|
|
50
|
+
`(?:за\\s*(?:1\\s*)?|по\\s*|per\\s+)${PER_SQM_UNIT}\\s*[:=\\-–—]?\\s*${PER_SQM_NUMBER}${PER_SQM_CURRENCY}(?=$|[^\\p{L}\\p{N}_])`,
|
|
51
|
+
'igu',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
function parsedMoneyAmount(numberValue, scaleValue) {
|
|
55
|
+
const amount = scaleValue
|
|
56
|
+
? parseScaledAmount(numberValue, scaleValue)
|
|
57
|
+
: parseNumericAmount(numberValue);
|
|
58
|
+
return amount != null && amount >= 1 && amount <= 5_000_000_000
|
|
59
|
+
? Math.round(amount)
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const APPROXIMATE_RE = /около|примерно|~|≈/iu;
|
|
64
|
+
|
|
65
|
+
function perSquareMeterMatches(text, fallbackCurrency = '') {
|
|
66
|
+
const matches = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
|
|
69
|
+
for (const regex of [PER_SQM_AFTER_AMOUNT_RE, PER_SQM_AFTER_UNIT_RE, PER_SQM_BEFORE_AMOUNT_RE]) {
|
|
70
|
+
regex.lastIndex = 0;
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = regex.exec(text)) !== null) {
|
|
73
|
+
const amount = parsedMoneyAmount(match[1], match[2]);
|
|
74
|
+
if (amount == null) continue;
|
|
75
|
+
const start = match.index ?? 0;
|
|
76
|
+
const end = regex.lastIndex;
|
|
77
|
+
const key = `${start}:${end}`;
|
|
78
|
+
if (seen.has(key)) continue;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
matches.push({
|
|
81
|
+
start,
|
|
82
|
+
end,
|
|
83
|
+
amount,
|
|
84
|
+
currency: moneyCurrencyFromText(match[0], fallbackCurrency || '') || fallbackCurrency || '',
|
|
85
|
+
approximate: APPROXIMATE_RE.test(match[0]),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return matches.sort((a, b) => a.start - b.start);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function maskRanges(text, ranges) {
|
|
94
|
+
if (!ranges.length) return text;
|
|
95
|
+
let masked = text;
|
|
96
|
+
for (const { start, end } of [...ranges].sort((a, b) => b.start - a.start)) {
|
|
97
|
+
masked = `${masked.slice(0, start)}${' '.repeat(Math.max(0, end - start))}${masked.slice(end)}`;
|
|
98
|
+
}
|
|
99
|
+
return masked;
|
|
100
|
+
}
|
|
101
|
+
|
|
33
102
|
function isPaymentScopedAmount(text, start, end) {
|
|
34
103
|
const left = text
|
|
35
104
|
.slice(Math.max(0, start - 56), start)
|
|
@@ -48,7 +117,23 @@ function isPaymentScopedAmount(text, start, end) {
|
|
|
48
117
|
&& Boolean(findCanonical(right, PAYMENT_AMOUNT_TERMS, { partial: true }));
|
|
49
118
|
}
|
|
50
119
|
|
|
51
|
-
|
|
120
|
+
export function parseHousingPricePerSqm(value, fallbackCurrency = '') {
|
|
121
|
+
const original = String(value || '');
|
|
122
|
+
if (!original) return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
|
|
123
|
+
|
|
124
|
+
const text = maskPhoneLikeSpans(original);
|
|
125
|
+
const candidates = perSquareMeterMatches(text, fallbackCurrency);
|
|
126
|
+
if (!candidates.length) {
|
|
127
|
+
return Object.freeze({ amount: null, currency: fallbackCurrency || '', approximate: false });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const candidate = candidates[0];
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
amount: candidate.amount,
|
|
133
|
+
currency: candidate.currency,
|
|
134
|
+
approximate: candidate.approximate,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
52
137
|
|
|
53
138
|
export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
54
139
|
const original = String(value || '');
|
|
@@ -57,15 +142,22 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
57
142
|
// Contact spans are removed once, before every money branch. A phone can
|
|
58
143
|
// therefore never win as a labelled, currency-tagged or fallback amount.
|
|
59
144
|
const text = maskPhoneLikeSpans(original);
|
|
60
|
-
|
|
61
|
-
|
|
145
|
+
// Unit prices are not listing totals. Mask their exact spans before running
|
|
146
|
+
// the ordinary total-price parser so "От 13 млн за м2" cannot become a
|
|
147
|
+
// 13,000,000 UZS apartment price. The dedicated parser above still exposes
|
|
148
|
+
// the unit price to consumers that need it.
|
|
149
|
+
const priceText = maskRanges(text, perSquareMeterMatches(text, fallbackCurrency));
|
|
150
|
+
let currency = moneyCurrencyFromText(priceText, fallbackCurrency || '')
|
|
151
|
+
|| moneyCurrencyFromText(text, fallbackCurrency || '')
|
|
152
|
+
|| '';
|
|
153
|
+
const explicit = Boolean(findCanonical(priceText, CURRENCY_TERMS, { partial: true }));
|
|
62
154
|
let price = null;
|
|
63
155
|
|
|
64
156
|
// Common Ukrainian/Russian classifieds shorthand: "10 т грн" / "10 т гр".
|
|
65
157
|
// Keep this housing-specific instead of adding globally ambiguous aliases
|
|
66
158
|
// `т` (tonne) and `гр` (gram) to the shared money lexicon. A nearby price
|
|
67
159
|
// keyword plus an explicit hryvnia shorthand makes the intent unambiguous.
|
|
68
|
-
const compactThousandUah =
|
|
160
|
+
const compactThousandUah = priceText.match(new RegExp(
|
|
69
161
|
`${PRICE_KEYWORD}[^\\r\\n]{0,48}?(\\d{1,6}(?:[.,]\\d{1,2})?)\\s*т(?:ыс\\.?)?\\s*(?:гр(?:н)?|₴|uah)(?=$|[^\\p{L}\\p{N}_])`,
|
|
70
162
|
'iu',
|
|
71
163
|
));
|
|
@@ -79,7 +171,7 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
79
171
|
|
|
80
172
|
// Uzbek ads also use a dot as a thousands separator after four leading
|
|
81
173
|
// digits: "2500.000 сум" means 2,500,000 UZS, not 2,500 UZS.
|
|
82
|
-
const expandedUzbekThousands =
|
|
174
|
+
const expandedUzbekThousands = priceText.match(new RegExp(
|
|
83
175
|
`${PRICE_KEYWORD}[^\\d\\r\\n]{0,16}(\\d{4})[.]000\\s*(?:с[ўу]м|so['‘’ʻʼ]?m|som|sum|uzs)(?=$|[^\\p{L}\\p{N}_])`,
|
|
84
176
|
'iu',
|
|
85
177
|
));
|
|
@@ -91,10 +183,36 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
91
183
|
}
|
|
92
184
|
}
|
|
93
185
|
|
|
94
|
-
|
|
186
|
+
// Split-million forms must win before a generic labelled amount. Otherwise
|
|
187
|
+
// "Narxi 2 миллион 500" would stop at 2,000,000 and lose the trailing 500k.
|
|
188
|
+
if (price == null) {
|
|
189
|
+
const splitMillion = priceText.match(/(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/iu);
|
|
190
|
+
if (splitMillion) {
|
|
191
|
+
const millions = Number(splitMillion[1]);
|
|
192
|
+
const thousands = Number(splitMillion[2]);
|
|
193
|
+
const amount = millions * 1_000_000 + thousands * 1_000;
|
|
194
|
+
if (amount >= 1_000_000 && amount <= 5_000_000_000) price = amount;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const labelled = priceText.match(new RegExp(
|
|
199
|
+
`${PRICE_KEYWORD}\\s*[:\\-–—]?\\s*(${MONEY_NUMBER_PATTERN})(?:\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_]))?`,
|
|
200
|
+
'iu',
|
|
201
|
+
));
|
|
95
202
|
if (price == null && labelled) {
|
|
96
|
-
const
|
|
97
|
-
|
|
203
|
+
const baseAmount = parseNumericAmount(labelled[1]);
|
|
204
|
+
// A common malformed Uzbek marketplace form is "450000 ming som". The
|
|
205
|
+
// already-expanded amount is the intended 450,000; multiplying it by
|
|
206
|
+
// another thousand would create a 450,000,000 false price. Keep normal
|
|
207
|
+
// shorthand such as "450 ming" and "2500 ming" scaled.
|
|
208
|
+
const ignoreRepeatedUzbekThousand = labelled[2]
|
|
209
|
+
&& /^(?:ming|минг)$/iu.test(labelled[2])
|
|
210
|
+
&& baseAmount != null
|
|
211
|
+
&& baseAmount >= 10_000;
|
|
212
|
+
const amount = labelled[2] && !ignoreRepeatedUzbekThousand
|
|
213
|
+
? parseScaledAmount(labelled[1], labelled[2])
|
|
214
|
+
: baseAmount;
|
|
215
|
+
if (amount != null && amount >= 50 && amount <= 5_000_000_000) price = Math.round(amount);
|
|
98
216
|
}
|
|
99
217
|
|
|
100
218
|
if (price == null) {
|
|
@@ -109,8 +227,8 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
109
227
|
const reSymNum = new RegExp(`${PRICE_CURRENCY_BEFORE_NUMBER}\\s*(${MONEY_NUMBER_PATTERN})`, 'igu');
|
|
110
228
|
for (const regex of [reNumSym, reSymNum]) {
|
|
111
229
|
let match;
|
|
112
|
-
while ((match = regex.exec(
|
|
113
|
-
if (isPaymentScopedAmount(
|
|
230
|
+
while ((match = regex.exec(priceText)) !== null) {
|
|
231
|
+
if (isPaymentScopedAmount(priceText, match.index ?? 0, regex.lastIndex)) continue;
|
|
114
232
|
const amount = parseNumericAmount(match[1]);
|
|
115
233
|
if (amount != null && amount >= 50 && amount <= 5_000_000_000 && (tagged == null || amount > tagged)) tagged = amount;
|
|
116
234
|
}
|
|
@@ -118,27 +236,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
118
236
|
price = tagged;
|
|
119
237
|
}
|
|
120
238
|
|
|
121
|
-
// Uzbek classifieds often split a round million and the trailing thousands:
|
|
122
|
-
// "2 млн 500" / "2 миллион 500" means 2,500,000, not 2,000,000 plus an unrelated 500.
|
|
123
|
-
if (price == null) {
|
|
124
|
-
const splitMillion = text.match(/(?:^|[^\p{L}\p{N}_])(\d{1,3})\s*(?:млн\.?|mln\.?|миллион(?:а|ов)?|million(?:s)?)\s+(\d{1,3})(?=$|[^\p{L}\p{N}_])/iu);
|
|
125
|
-
if (splitMillion) {
|
|
126
|
-
const millions = Number(splitMillion[1]);
|
|
127
|
-
const thousands = Number(splitMillion[2]);
|
|
128
|
-
const amount = millions * 1_000_000 + thousands * 1_000;
|
|
129
|
-
if (amount >= 1_000_000 && amount <= 5_000_000_000) price = amount;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
239
|
if (price == null) {
|
|
134
|
-
const scalePattern = [...new Set(NUMBER_MULTIPLIERS.flatMap((entry) => aliasesOf(entry)).filter(Boolean))]
|
|
135
|
-
.sort((a, b) => String(b).length - String(a).length)
|
|
136
|
-
.map(escapeRegex)
|
|
137
|
-
.join('|');
|
|
138
240
|
// Multiplier aliases include useful one-letter forms such as `m` and `k`.
|
|
139
241
|
// Require the alias to end at a token boundary so measurements/words like
|
|
140
242
|
// `500 m2` and `5 minut` cannot be promoted to 500 million / 5 million.
|
|
141
|
-
const match =
|
|
243
|
+
const match = priceText.match(new RegExp(`(\\d+(?:[.,]\\d+)?)\\s*(${SCALE_PATTERN})(?=$|[^\\p{L}\\p{N}_])`, 'iu'));
|
|
142
244
|
if (match) {
|
|
143
245
|
const amount = parseScaledAmount(match[1], match[2]);
|
|
144
246
|
if (amount != null && amount >= 1000 && amount <= 5_000_000_000) price = Math.round(amount);
|
|
@@ -148,10 +250,10 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
148
250
|
if (price == null) {
|
|
149
251
|
const bareAmountRe = /\d{1,3}(?:[ \u00A0.,]\d{3})+|\d{4,}/g;
|
|
150
252
|
let best = null;
|
|
151
|
-
for (const match of
|
|
253
|
+
for (const match of priceText.matchAll(bareAmountRe)) {
|
|
152
254
|
const raw = match[0];
|
|
153
255
|
const start = match.index ?? 0;
|
|
154
|
-
if (isPaymentScopedAmount(
|
|
256
|
+
if (isPaymentScopedAmount(priceText, start, start + raw.length)) continue;
|
|
155
257
|
const digits = raw.replace(/[\s.,]/g, '');
|
|
156
258
|
if (digits[0] === '0') continue;
|
|
157
259
|
const amount = parseNumericAmount(raw);
|
|
@@ -161,11 +263,11 @@ export function parseHousingPrice(value, fallbackCurrency = '') {
|
|
|
161
263
|
}
|
|
162
264
|
|
|
163
265
|
if (!explicit && fallbackCurrency === 'UZS' && price != null) {
|
|
164
|
-
const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(
|
|
266
|
+
const dailyUzbek = /(?:kunlik|sutkaga|kecha[- ]?kunduz|посуточн|суточн)/i.test(priceText);
|
|
165
267
|
currency = price >= 1_000_000 || (dailyUzbek && price >= 10_000) ? 'UZS' : 'USD';
|
|
166
268
|
}
|
|
167
269
|
|
|
168
|
-
return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(
|
|
270
|
+
return Object.freeze({ amount: price, currency, approximate: price != null && APPROXIMATE_RE.test(priceText) });
|
|
169
271
|
}
|
|
170
272
|
|
|
171
273
|
// Compatibility name for callers migrating from Flat Finder's local parser.
|
|
@@ -102,13 +102,17 @@ export function parseHousingFloor(value) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Prefer the common "7 этаж" form before trying "этаж 7". Otherwise the
|
|
106
|
+
// marker-first parser can consume the next unrelated number (for example
|
|
107
|
+
// "7 этаж 44м²") and report floor 44.
|
|
108
|
+
const beforeMarker = text.match(/(?:^|[^\d])(\d{1,3})\s*-?\s*(?:(?:chi|чи)\s*)?(?:этаж(?:да)?|поверх|floor|etaj|qavat(?:i(?:da(?:gi)?)?|da)?|қабат(?:ы(?:нда(?:ғы)?)?|та)?|кават(?:и(?:да(?:ги)?)?|да)?|қават(?:и(?:да(?:ги)?)?|да)?)(?=$|[^\p{L}\p{N}_])/iu);
|
|
109
|
+
let floor = toNumber(beforeMarker?.[1]);
|
|
110
|
+
let totalFloors = null;
|
|
108
111
|
|
|
109
112
|
if (floor == null) {
|
|
110
|
-
const
|
|
111
|
-
floor = toNumber(
|
|
113
|
+
const explicit = text.match(/(?:этаж|поверх|floor|etaj|qavat|қабат|кават|қават)[^\S\r\n]*[:№#-]?[^\S\r\n]*(\d{1,3})[^\S\r\n]*(?:[,;/]|из|of|din|dan)?[^\S\r\n]*(?:дом[^\S\r\n]*)?(?:из[^\S\r\n]*)?(\d{1,3})?[^\S\r\n]*(?:этаж\p{L}*|поверх\p{L}*|floors?|etaje|qavatli|қабатты|каватли|қаватли)?/iu);
|
|
114
|
+
floor = toNumber(explicit?.[1]);
|
|
115
|
+
totalFloors = toNumber(explicit?.[2]);
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
if (floor == null && FIRST_FLOOR_WORD_RE.test(text)) floor = 1;
|
package/src/money.js
CHANGED
|
@@ -101,6 +101,10 @@ function rangeSearchText(text) {
|
|
|
101
101
|
for (const symbol of Object.keys(CURRENCY_SYMBOL_CANDIDATES)) {
|
|
102
102
|
normalized = normalized.split(symbol).join(' '.repeat(symbol.length));
|
|
103
103
|
}
|
|
104
|
+
// Some job boards render salary parts as separate UI tokens and join their
|
|
105
|
+
// accessible text with middle dots: "2 500 · - 3 000 · €". Treat those
|
|
106
|
+
// decorative separators as whitespace while preserving source offsets.
|
|
107
|
+
normalized = normalized.replace(/[·•]/gu, ' ');
|
|
104
108
|
return normalized;
|
|
105
109
|
}
|
|
106
110
|
|