@whiteslove/parsing-lexicon 0.8.1 → 0.8.4
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 +8 -2
- package/src/central-asia-locations.js +4 -1
- package/src/contact.js +36 -7
- package/src/geography-central-asia.js +13 -4
- package/src/geography-detection.js +27 -6
- package/src/geography.js +3 -1
- package/src/hiring-ats.js +5 -1
- package/src/hiring-context.js +18 -3
- package/src/hiring-requirements.d.ts +3 -0
- package/src/hiring-requirements.js +30 -2
- package/src/hiring-skills.js +3 -2
- package/src/hiring-source-semantics.js +5 -5
- package/src/housing-address.d.ts +11 -0
- package/src/housing-address.js +73 -13
- package/src/housing-intent.d.ts +8 -0
- package/src/housing-intent.js +40 -0
- package/src/housing-listing-enrichment.d.ts +3 -0
- package/src/housing-listing-enrichment.js +16 -1
- package/src/housing-listing-fields.js +34 -0
- package/src/housing-money.d.ts +1 -0
- package/src/housing-money.js +26 -10
- package/src/housing-poi-relations.js +4 -4
- package/src/housing-structured.d.ts +1 -0
- package/src/housing-structured.js +22 -11
- package/src/landmarks.js +2 -0
- package/src/location-data.js +5 -0
- package/src/locations.js +65 -1
- package/src/semantic-spans.d.ts +19 -0
- package/src/semantic-spans.js +83 -0
- package/src/tashkent-housing-geography.js +17 -9
- package/src/tashkent-residential-complexes.js +1 -0
- package/src/temporal.js +52 -7
- package/src/uz-bukhara-location-extensions.js +15 -0
- package/src/uz-map-data-location-extensions.js +74377 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whiteslove/parsing-lexicon",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -193,6 +193,10 @@
|
|
|
193
193
|
"types": "./src/housing-money.d.ts",
|
|
194
194
|
"import": "./src/housing-money.js"
|
|
195
195
|
},
|
|
196
|
+
"./semantic-spans": {
|
|
197
|
+
"types": "./src/semantic-spans.d.ts",
|
|
198
|
+
"import": "./src/semantic-spans.js"
|
|
199
|
+
},
|
|
196
200
|
"./hiring-professions": "./src/hiring-professions.js",
|
|
197
201
|
"./hiring-profession-display": {
|
|
198
202
|
"types": "./src/hiring-profession-display.d.ts",
|
|
@@ -226,7 +230,9 @@
|
|
|
226
230
|
"node": ">=20"
|
|
227
231
|
},
|
|
228
232
|
"scripts": {
|
|
229
|
-
"test": "node --test"
|
|
233
|
+
"test": "node --test",
|
|
234
|
+
"sync:geo-map-data": "node ./scripts/sync-geo-map-data-lexicon.js",
|
|
235
|
+
"audit:geo-map-data": "node ./scripts/audit-geo-map-data-lexicon.js"
|
|
230
236
|
},
|
|
231
237
|
"publishConfig": {
|
|
232
238
|
"access": "public"
|
|
@@ -66,7 +66,10 @@ function explicitCityFromText(text, countryCode) {
|
|
|
66
66
|
const start = match.index || 0;
|
|
67
67
|
const before = value.slice(Math.max(0, start - 40), start);
|
|
68
68
|
const after = value.slice(start + match[0].length, start + match[0].length + 48);
|
|
69
|
-
|
|
69
|
+
// Kept in sync with geography-detection.js's CITY_CONTEXT_RE breadth
|
|
70
|
+
// (shahr/шаар/город/city/viloyat/област forms) plus the Xonobod-
|
|
71
|
+
// specific Andijan-region markers this catalog also relies on.
|
|
72
|
+
if (!/(?:shahr(?:i)?|шаар(?:ы|ында|ына|ынан)?|город(?:а|е|у|ом)?|city|viloyat(?:i)?|област\p{L}{0,4}|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
|
|
70
73
|
}
|
|
71
74
|
matches.push({ item, length: normalizeForMatch(match[0]).length });
|
|
72
75
|
}
|
package/src/contact.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parsePhoneNumberFromString } from 'libphonenumber-js/min';
|
|
2
|
+
import { moneyCurrencyPattern } from './money-core.js';
|
|
2
3
|
|
|
3
4
|
// Broad phone-like detection used by other parsers for exclusion/classification.
|
|
4
5
|
// It deliberately stays tolerant and does not validate against a country plan.
|
|
@@ -6,15 +7,29 @@ const PHONE_LIKE_RE = /\+?\d(?:[\t \u00a0().-]*\d){9,}/g;
|
|
|
6
7
|
|
|
7
8
|
// Contact extraction may start from shorter national formats, but candidates are
|
|
8
9
|
// only returned after libphonenumber validation.
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const PHONE_EXTENSION_ALTERNATION = 'ext\\.?|extension|x|доб\\.?|дод\\.?';
|
|
11
|
+
const PHONE_CANDIDATE_RE = new RegExp(`\\+?\\d(?:[\\t \\u00a0().-]*\\d){6,}(?:[\\t \\u00a0]*(?:${PHONE_EXTENSION_ALTERNATION})\\s*\\d{1,6})?`, 'giu');
|
|
12
|
+
const PHONE_EXTENSION_RE = new RegExp(`[\\t \\u00a0]*(?:${PHONE_EXTENSION_ALTERNATION})\\s*(\\d{1,6})$`, 'iu');
|
|
11
13
|
const DATE_LIKE_PHONE_RE = /^\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})(?:\s+\d{1,2})?$/u;
|
|
12
14
|
const PRICE_LABEL_BEFORE_NUMBER_RE = /(?:цена|ціна|нарх(?:и)?|narx(?:i)?|price|стоимост[ьи]|аренд(?:а|ная\s+плата)?|rent)\s*[:=\-–—]?\s*$/iu;
|
|
15
|
+
// A currency term/symbol adjacent to a hyphenated digit span is evidence of a
|
|
16
|
+
// price range even without an explicit label word ("50000-60000 сум"); a bare
|
|
17
|
+
// phone number never carries one. Both sides are fully bounded (unlike
|
|
18
|
+
// housing-money.js's number-adjacent variant) since this only scans nearby
|
|
19
|
+
// window text, not text touching the digits themselves.
|
|
20
|
+
const CURRENCY_TERM_NEARBY_RE = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${moneyCurrencyPattern()})(?![\\p{L}\\p{N}_])`, 'iu');
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
// Real Telegram usernames must start with a letter (Telegram itself rejects
|
|
23
|
+
// a digit-led one), so a digit-led "handle" like "@12345_promo" is more
|
|
24
|
+
// likely an order/SKU code than a contact.
|
|
25
|
+
const TELEGRAM_USERNAME_RE = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
|
|
15
26
|
const TELEGRAM_LINK_RE = /(?:https?:\/\/)?(?:t\.me|telegram\.me|telegram\.dog)\/([A-Za-z0-9_]{5,32})(?:\/[0-9]+)?(?:[/?#][^\s]*)?/giu;
|
|
16
27
|
const TELEGRAM_TG_RE = /tg:\/\/resolve\?[^\s]*?\bdomain=([A-Za-z0-9_]{5,32})\b[^\s]*/giu;
|
|
17
28
|
const TELEGRAM_MENTION_RE = /(^|[^\p{L}\p{N}_@])@([A-Za-z0-9_]{5,32})\b/gu;
|
|
29
|
+
// Reserved t.me path segments (joinchat/share/... carry no real handle) and
|
|
30
|
+
// app-name mentions people write as "@Telegram"/"@WhatsApp" — neither is a
|
|
31
|
+
// contactable personal username.
|
|
32
|
+
const RESERVED_TELEGRAM_NAME_RE = /^(?:joinchat|share|addstickers|addtheme|addemoji|confirmphone|login|proxy|socks|iv|s|boost|giftcode|setlanguage|telegram|whatsapp|viber|instagram|facebook)$/iu;
|
|
18
33
|
|
|
19
34
|
function normalizedCountryHint(value) {
|
|
20
35
|
const country = String(value || '').trim().toUpperCase();
|
|
@@ -30,7 +45,10 @@ function normalizedCountryHint(value) {
|
|
|
30
45
|
function isExplicitPriceSpan(text, start, raw) {
|
|
31
46
|
if (!/[\-–—]/u.test(raw)) return false;
|
|
32
47
|
const before = text.slice(Math.max(0, start - 48), start);
|
|
33
|
-
|
|
48
|
+
if (PRICE_LABEL_BEFORE_NUMBER_RE.test(before)) return true;
|
|
49
|
+
const end = start + raw.length;
|
|
50
|
+
const after = text.slice(end, Math.min(text.length, end + 24));
|
|
51
|
+
return CURRENCY_TERM_NEARBY_RE.test(before) || CURRENCY_TERM_NEARBY_RE.test(after);
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
function splitPhoneExtension(raw) {
|
|
@@ -93,11 +111,19 @@ export function maskPhoneLikeSpans(value, replacement = ' ', options = {}) {
|
|
|
93
111
|
const spans = findPhoneLikeSpans(text, options);
|
|
94
112
|
if (!spans.length) return text;
|
|
95
113
|
|
|
114
|
+
// The replacement must fill the exact span length. A single-character
|
|
115
|
+
// replacement collapsing a whole multi-digit phone span down to one
|
|
116
|
+
// character silently shifts every character offset after it, which is
|
|
117
|
+
// harmless to a caller that only reads back matched substrings but
|
|
118
|
+
// corrupts any offset (start/end) computed against this masked text and
|
|
119
|
+
// later compared to the original, unmasked string.
|
|
120
|
+
const fill = String(replacement || ' ') || ' ';
|
|
96
121
|
let out = '';
|
|
97
122
|
let cursor = 0;
|
|
98
123
|
for (const span of spans) {
|
|
99
124
|
out += text.slice(cursor, span.start);
|
|
100
|
-
|
|
125
|
+
const spanLength = span.end - span.start;
|
|
126
|
+
out += fill.repeat(Math.ceil(spanLength / fill.length)).slice(0, spanLength);
|
|
101
127
|
cursor = span.end;
|
|
102
128
|
}
|
|
103
129
|
return out + text.slice(cursor);
|
|
@@ -154,7 +180,7 @@ export function normalizePhone(value, options = {}) {
|
|
|
154
180
|
|
|
155
181
|
function telegramContact(username, raw, start, source) {
|
|
156
182
|
const normalized = String(username || '').replace(/^@/, '');
|
|
157
|
-
if (!TELEGRAM_USERNAME_RE.test(normalized)) return null;
|
|
183
|
+
if (!TELEGRAM_USERNAME_RE.test(normalized) || RESERVED_TELEGRAM_NAME_RE.test(normalized)) return null;
|
|
158
184
|
return Object.freeze({
|
|
159
185
|
start,
|
|
160
186
|
end: start + raw.length,
|
|
@@ -214,7 +240,10 @@ export function parsePrimaryContact(value) {
|
|
|
214
240
|
}
|
|
215
241
|
// Bounded like the `trailing` keyword below: "тел"/"phone" must be a whole
|
|
216
242
|
// word, not a suffix of an unrelated word ("хостел", "котел").
|
|
217
|
-
|
|
243
|
+
// Widened beyond bare stems to cover the conjugated imperative forms
|
|
244
|
+
// ('Звоните', 'Позвоните', 'Наберите', 'Дзвоніть') that are the actual
|
|
245
|
+
// everyday phrasing in CIS classifieds — the bare stems alone missed them.
|
|
246
|
+
const keyword = text.match(/(?<![\p{L}\p{N}_])(?:tel|тел|phone|моб|whats?app|viber|telegram|(?:по|пере|за)?звонит\p{L}*|(?:за|під)?дзвоніть\p{L}*|звоніть\p{L}*|наберит\p{L}*|номер\p{L}*|aloqa|byla|contact)(?![\p{L}\p{N}_])[^\d+]{0,20}(\+?\d[\d\s().-]{6,}\d)/iu);
|
|
218
247
|
if (keyword) {
|
|
219
248
|
const digits = keyword[1].replace(/\D/g, '');
|
|
220
249
|
if (digits.length >= 9 && digits.length <= 15) return keyword[1].trim();
|
|
@@ -76,7 +76,10 @@ const KZ_BASE_CITIES = Object.freeze([
|
|
|
76
76
|
entity('Karaganda', { kk: ['Қарағанды'], ru: ['Караганда'], en: ['Karaganda', 'Qaragandy'] }, { country: 'KZ' }),
|
|
77
77
|
entity('Aktobe', { kk: ['Ақтөбе'], ru: ['Актобе'], en: ['Aktobe', 'Aqtobe'] }, { country: 'KZ' }),
|
|
78
78
|
entity('Atyrau', { kk: ['Атырау'], ru: ['Атырау'], en: ['Atyrau'] }, { country: 'KZ' }),
|
|
79
|
-
|
|
79
|
+
// "Oral" is an ordinary English word ("an oral agreement"); "Уральск"/
|
|
80
|
+
// "Uralsk" are unambiguous and stay unguarded, only the short aliases need
|
|
81
|
+
// nearby city context.
|
|
82
|
+
entity('Oral', { kk: ['Орал'], ru: ['Уральск', 'Орал'], en: ['Oral', 'Uralsk'] }, { country: 'KZ', contextRequiredAliases: ['Oral', 'Орал'] }),
|
|
80
83
|
entity('Taraz', { kk: ['Тараз'], ru: ['Тараз', 'Джамбул'], en: ['Taraz'] }, { country: 'KZ' }),
|
|
81
84
|
entity('Pavlodar', { kk: ['Павлодар'], ru: ['Павлодар'], en: ['Pavlodar'] }, { country: 'KZ' }),
|
|
82
85
|
entity('Semey', { kk: ['Семей'], ru: ['Семей', 'Семипалатинск'], en: ['Semey', 'Semipalatinsk'] }, { country: 'KZ' }),
|
|
@@ -148,7 +151,10 @@ export const KZ_CITY_ADDITIONS = Object.freeze([
|
|
|
148
151
|
catalogCity('Kurchatov', { kk: ['Курчатов'], ru: ['Курчатов'], en: ['Kurchatov'] }, { country: 'KZ', priority: 'P4' }),
|
|
149
152
|
catalogCity('Ayagoz', { kk: ['Аягөз'], ru: ['Аягоз'], en: ['Ayagoz'] }, { country: 'KZ', priority: 'P4' }),
|
|
150
153
|
catalogCity('Khromtau', { kk: ['Хромтау'], ru: ['Хромтау'], en: ['Khromtau'] }, { country: 'KZ', priority: 'P4' }),
|
|
151
|
-
|
|
154
|
+
// "Alga" (a common Kazakh exclamation, "forward!") and "alga" (the English
|
|
155
|
+
// word for algae) collide badly in ordinary prose, with no unambiguous
|
|
156
|
+
// longer alias available.
|
|
157
|
+
catalogCity('Alga', { kk: ['Алға'], ru: ['Алга'], en: ['Alga'] }, { country: 'KZ', priority: 'P4', contextRequired: true }),
|
|
152
158
|
catalogCity('Kandyagash', { kk: ['Қандыағаш'], ru: ['Кандыагаш'], en: ['Kandyagash'] }, { country: 'KZ', priority: 'P4' }),
|
|
153
159
|
catalogCity('Shalkar', { kk: ['Шалқар'], ru: ['Шалкар'], en: ['Shalkar'] }, { country: 'KZ', priority: 'P4' }),
|
|
154
160
|
catalogCity('Kulsary', { kk: ['Құлсары'], ru: ['Кульсары'], en: ['Kulsary'] }, { country: 'KZ', priority: 'P4' }),
|
|
@@ -160,9 +166,12 @@ export const KZ_CITY_ADDITIONS = Object.freeze([
|
|
|
160
166
|
catalogCity('Zhitikara', { kk: ['Жітіқара'], ru: ['Житикара'], en: ['Zhitikara'] }, { country: 'KZ', priority: 'P4' }),
|
|
161
167
|
catalogCity('Aksai', { kk: ['Ақсай'], ru: ['Аксай'], en: ['Aksai'] }, { country: 'KZ', priority: 'P4' }),
|
|
162
168
|
catalogCity('Baikonur', { kk: ['Байқоңыр'], ru: ['Байконур'], en: ['Baikonur'] }, { country: 'KZ', priority: 'P4', type: 'special_status_city' }),
|
|
163
|
-
|
|
169
|
+
// "Aral"/"Арал" collide with "Aral Sea" mentions; "Аральск"/"Aralsk" stay
|
|
170
|
+
// unguarded since they're unambiguous.
|
|
171
|
+
catalogCity('Aral', { kk: ['Арал'], ru: ['Аральск', 'Арал'], en: ['Aral', 'Aralsk'] }, { country: 'KZ', priority: 'P4', contextRequiredAliases: ['Aral', 'Арал'] }),
|
|
164
172
|
catalogCity('Kazaly', { kk: ['Қазалы'], ru: ['Казалинск', 'Казалы'], en: ['Kazaly', 'Kazalinsk'] }, { country: 'KZ', priority: 'P4' }),
|
|
165
|
-
|
|
173
|
+
// "Shu" is a two-letter token with no unambiguous longer alias.
|
|
174
|
+
catalogCity('Shu', { kk: ['Шу'], ru: ['Шу'], en: ['Shu'] }, { country: 'KZ', priority: 'P4', contextRequired: true }),
|
|
166
175
|
catalogCity('Karatau', { kk: ['Қаратау'], ru: ['Каратау'], en: ['Karatau'] }, { country: 'KZ', priority: 'P4' }),
|
|
167
176
|
catalogCity('Zhanatas', { kk: ['Жаңатас'], ru: ['Жанатас'], en: ['Zhanatas'] }, { country: 'KZ', priority: 'P4' }),
|
|
168
177
|
catalogCity('Merke', { kk: ['Мерке'], ru: ['Мерке'], en: ['Merke'] }, { country: 'KZ', priority: 'P4' }),
|
|
@@ -66,8 +66,18 @@ export function detectCountryCodeFromText(value) {
|
|
|
66
66
|
const exact = canonicalCountryCode(text);
|
|
67
67
|
if (exact) return exact;
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Multiple countries can legitimately be mentioned in one text (e.g. a
|
|
70
|
+
// relocation ad, "from X to Y"). Prefer whichever is mentioned first
|
|
71
|
+
// rather than whichever happens to be declared first in COUNTRY_MATCHERS.
|
|
72
|
+
let earliestCountry = null;
|
|
73
|
+
let earliestStart = Infinity;
|
|
74
|
+
for (const { item, re } of COUNTRY_MATCHERS) {
|
|
75
|
+
const match = text.match(re);
|
|
76
|
+
if (!match) continue;
|
|
77
|
+
const start = match.index ?? 0;
|
|
78
|
+
if (start < earliestStart) { earliestStart = start; earliestCountry = item; }
|
|
79
|
+
}
|
|
80
|
+
if (earliestCountry?.code) return earliestCountry.code;
|
|
71
81
|
|
|
72
82
|
// Keep dotted U.S. and explicit "remote US" support without treating the
|
|
73
83
|
// ordinary English pronoun "us" as a geography signal.
|
|
@@ -81,10 +91,21 @@ export function detectCityFromText(value, country = null) {
|
|
|
81
91
|
const text = String(value || '');
|
|
82
92
|
if (!text) return null;
|
|
83
93
|
const code = country ? canonicalCountryCode(country) : null;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
// Prefer whichever known city is mentioned first in the text, not whichever
|
|
95
|
+
// is declared first in CITY_MATCHERS (see detectCitiesFromText, which
|
|
96
|
+
// already orders by match position — this mirrors that for the single-hit
|
|
97
|
+
// case).
|
|
98
|
+
let earliest = null;
|
|
99
|
+
let earliestStart = Infinity;
|
|
100
|
+
for (const matcher of CITY_MATCHERS) {
|
|
101
|
+
if (code && matcher.item.country !== code) continue;
|
|
102
|
+
const match = cityTextMatch(text, matcher);
|
|
103
|
+
if (!match) continue;
|
|
104
|
+
const start = match.index ?? 0;
|
|
105
|
+
if (start < earliestStart) { earliestStart = start; earliest = matcher.item; }
|
|
106
|
+
}
|
|
107
|
+
if (!earliest) return null;
|
|
108
|
+
return Object.freeze({ canonical: earliest.canonical, country: earliest.country || null });
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/** Detect every known city in free text, ordered by first mention and deduplicated by canonical name. */
|
package/src/geography.js
CHANGED
|
@@ -67,7 +67,9 @@ export const KG_CITIES = Object.freeze([
|
|
|
67
67
|
entity('Batken', { ky: ['Баткен'], ru: ['Баткен'], en: ['Batken'] }, { country: 'KG', type: 'city' }),
|
|
68
68
|
entity('Kara-Balta', { ky: ['Кара-Балта', 'Кара Балта'], ru: ['Кара-Балта', 'Кара Балта'], en: ['Kara-Balta', 'Kara Balta'] }, { country: 'KG', type: 'city' }),
|
|
69
69
|
entity('Balykchy', { ky: ['Балыкчы'], ru: ['Балыкчи'], en: ['Balykchy', 'Balykchi'] }, { country: 'KG', type: 'city' }),
|
|
70
|
-
|
|
70
|
+
// "Kant" collides with the philosopher's name in ordinary English prose
|
|
71
|
+
// and has no unambiguous longer alias, unlike Manas/Jalal-Abad above.
|
|
72
|
+
entity('Kant', { ky: ['Кант'], ru: ['Кант'], en: ['Kant'] }, { country: 'KG', type: 'city', contextRequired: true }),
|
|
71
73
|
entity('Uzgen', { ky: ['Өзгөн', 'Озгон'], ru: ['Узген', 'Озгон'], en: ['Uzgen', 'Özgön', 'Ozgon'] }, { country: 'KG', type: 'city' }),
|
|
72
74
|
entity('Kyzyl-Kiya', { ky: ['Кызыл-Кыя', 'Кызыл Кыя'], ru: ['Кызыл-Кия', 'Кызыл Кия'], en: ['Kyzyl-Kiya', 'Kyzyl Kiya'] }, { country: 'KG', type: 'city' }),
|
|
73
75
|
entity('Aydarken', { ky: ['Айдаркен'], ru: ['Айдаркен', 'Хайдаркан'], en: ['Aydarken', 'Aidarken', 'Khaidarkan'] }, { country: 'KG', type: 'city' }),
|
package/src/hiring-ats.js
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
import { detectDegreeRequirement, detectHiringScopeSignals } from './hiring-semantics.js';
|
|
8
8
|
import { canonicalSkillName, extractSkillNames, matchSkillCandidates } from './hiring-skills.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// "other" is unclassified/preamble text (no recognized section heading yet).
|
|
11
|
+
// It must rank below every genuine section — otherwise keyword-stuffing
|
|
12
|
+
// outside any real heading would outscore the same term listed under an
|
|
13
|
+
// explicit Skills section, rewarding exactly the wrong signal.
|
|
14
|
+
const SECTION_WEIGHT = Object.freeze({ experience: 1, projects: 0.7, profile: 0.55, skills: 0.4, education: 0.35, other: 0.3 });
|
|
11
15
|
const DEGREE_RANK = Object.freeze({ secondary: 0, bachelor: 1, master: 2, doctorate: 3 });
|
|
12
16
|
const SCOPE_LABELS = Object.freeze({ architecture: 'Architecture / system design', leadership: 'Technical leadership', mentoring: 'Mentoring engineers', scale: 'Large-scale systems', ownership: 'Product / feature ownership' });
|
|
13
17
|
const TERM_STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'your', 'you', 'our', 'are', 'will', 'have', 'has', 'who', 'what', 'when', 'where', 'which', 'their', 'they', 'them', 'about', 'within', 'across', 'using', 'including', 'work', 'working', 'team', 'teams', 'role', 'company', 'years', 'year', 'experience', 'skills', 'skill', 'strong', 'good', 'excellent', 'ability', 'knowledge', 'looking', 'required', 'requirements', 'preferred', 'responsibilities', 'opportunity', 'candidate', 'position', 'professional', 'develop', 'development', 'build', 'building', 'software', 'engineer', 'engineering', 'help', 'support', 'ensure', 'provide', 'plus', 'nice', 'must', 'need', 'needs', 'для', 'что', 'как', 'или', 'это', 'мы', 'вы', 'ваш', 'ваша', 'ваши', 'наш', 'наша', 'наши', 'работа', 'работы', 'работать', 'опыт', 'лет', 'года', 'год', 'команда', 'команды', 'знание', 'знания', 'навыки', 'требования', 'обязанности', 'будет', 'нужно', 'необходимо', 'умение', 'разработка', 'разработки', 'позиция', 'кандидат']);
|
package/src/hiring-context.js
CHANGED
|
@@ -91,13 +91,28 @@ export const LOCATION_CONTEXT_TERMS = Object.freeze([
|
|
|
91
91
|
]);
|
|
92
92
|
|
|
93
93
|
export const WORK_AUTHORIZATION_TERMS = Object.freeze([
|
|
94
|
-
group('sponsorshipOffered', { ru: ['визовая поддержка', 'спонсируем рабочую визу', 'оформляем рабочую визу'], en: ['visa sponsorship available', 'visa sponsorship provided', 'we sponsor visas', 'sponsorship available'], uk: ['візова підтримка', 'спонсоруємо робочу візу'], ro: ['sponsorizare viză', 'sponsorizare pentru viză'], uzLatn: ['viza yordami'], uzCyrl: ['виза ёрдами'], kk: ['визаға демеушілік'] }),
|
|
94
|
+
group('sponsorshipOffered', { ru: ['визовая поддержка', 'спонсируем рабочую визу', 'оформляем рабочую визу', 'виза h-1b', 'спонсорство h-1b', 'спонсорство визы h1b'], en: ['visa sponsorship available', 'visa sponsorship provided', 'we sponsor visas', 'sponsorship available', 'h-1b sponsorship', 'h1b sponsorship'], uk: ['візова підтримка', 'спонсоруємо робочу візу'], ro: ['sponsorizare viză', 'sponsorizare pentru viză'], uzLatn: ['viza yordami'], uzCyrl: ['виза ёрдами'], kk: ['визаға демеушілік'] }),
|
|
95
95
|
group('noSponsorship', { ru: ['без визовой поддержки', 'визу не спонсируем', 'спонсорства визы нет'], en: ['no visa sponsorship', 'visa sponsorship is not available', 'we do not sponsor', 'unable to sponsor', 'cannot sponsor', 'no sponsorship'], uk: ['без візової підтримки', 'візу не спонсоруємо'], ro: ['fără sponsorizare pentru viză'], uzLatn: ['viza homiyligi yoq'], uzCyrl: ['виза ҳомийлиги йўқ'], kk: ['виза демеушілігі жоқ'] }),
|
|
96
|
-
|
|
96
|
+
// Bare "патент" is deliberately excluded: it also means an IP patent
|
|
97
|
+
// ("патентное право", "работа с патентами"), so only phrases that
|
|
98
|
+
// unambiguously mean the RF migrant work-permit document qualify.
|
|
99
|
+
group('workPermitRequired', { ru: ['разрешение на работу обязательно', 'нужно разрешение на работу', 'патент на работу', 'нужен патент', 'требуется патент', 'наличие патента', 'патент обязателен'], en: ['work permit required', 'must have work authorization', 'must be authorized to work', 'right to work required'], uk: ['дозвіл на роботу обов’язковий'], ro: ['permis de muncă obligatoriu'], uzLatn: ['ishlash ruxsati kerak'], uzCyrl: ['ишлаш рухсати керак'], kk: ['жұмыс істеуге рұқсат қажет'] }),
|
|
97
100
|
group('citizenshipRequired', { ru: ['только граждане', 'гражданство обязательно'], en: ['citizenship required', 'citizens only'], uk: ['лише громадяни', 'громадянство обов’язкове'], ro: ['cetățenie obligatorie'], uzLatn: ['faqat fuqarolar'], uzCyrl: ['фақат фуқаролар'], kk: ['тек азаматтар'] }),
|
|
98
101
|
group('residencePermit', { ru: ['внж', 'вид на жительство'], en: ['residence permit', 'residency permit'], uk: ['посвідка на проживання'], ro: ['permis de ședere'], uzLatn: ['yashash ruxsati'], uzCyrl: ['яшаш рухсати'], kk: ['тұруға ықтиярхат'] }),
|
|
99
102
|
]);
|
|
100
103
|
|
|
104
|
+
// workAuthorization can carry several matched canonicals from the same text
|
|
105
|
+
// (e.g. a posting that both restricts and offers). When a negative/restrictive
|
|
106
|
+
// signal and a positive sponsorship signal co-occur, the restriction is the
|
|
107
|
+
// more specific, deliberately-stated one — drop the contradictory positive.
|
|
108
|
+
const CONTRADICTS_SPONSORSHIP_OFFERED = Object.freeze(['noSponsorship', 'citizenshipRequired']);
|
|
109
|
+
|
|
110
|
+
function resolveWorkAuthorizationConflicts(canonicals) {
|
|
111
|
+
if (!canonicals.includes('sponsorshipOffered')) return canonicals;
|
|
112
|
+
if (!canonicals.some((item) => CONTRADICTS_SPONSORSHIP_OFFERED.includes(item))) return canonicals;
|
|
113
|
+
return canonicals.filter((item) => item !== 'sponsorshipOffered');
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export const HIRING_AVAILABILITY_TERMS = Object.freeze([
|
|
102
117
|
group('urgent', { ru: ['срочно нужен', 'срочно требуется', 'срочный набор'], en: ['urgent hire', 'hiring urgently', 'urgent opening'], uk: ['терміново потрібен'], ro: ['angajare urgentă'], uzLatn: ['zudlik bilan xodim kerak'], uzCyrl: ['зудлик билан ходим керак'], kk: ['шұғыл қызметкер керек'] }),
|
|
103
118
|
group('immediateStart', { ru: ['выход завтра', 'приступить сразу', 'выход сразу'], en: ['immediate start', 'start immediately', 'asap start'], uk: ['вийти одразу', 'почати одразу'], ro: ['începere imediată'], uzLatn: ['darhol ish boshlash'], uzCyrl: ['дарҳол иш бошлаш'], kk: ['бірден бастау'] }),
|
|
@@ -400,7 +415,7 @@ export function parseHiringContext(value, { title = '', mode = null } = {}) {
|
|
|
400
415
|
application: matchCanonicals(text, APPLICATION_TERMS),
|
|
401
416
|
companyContext: matchCanonicals(text, COMPANY_TERMS),
|
|
402
417
|
locationContext: matchCanonicals(text, LOCATION_CONTEXT_TERMS),
|
|
403
|
-
workAuthorization: matchCanonicals(text, WORK_AUTHORIZATION_TERMS),
|
|
418
|
+
workAuthorization: resolveWorkAuthorizationConflicts(matchCanonicals(text, WORK_AUTHORIZATION_TERMS)),
|
|
404
419
|
availability: matchCanonicals(text, HIRING_AVAILABILITY_TERMS),
|
|
405
420
|
travel: has(text, TRAVEL_TERMS),
|
|
406
421
|
relocation: has(text, RELOCATION_CONTEXT_TERMS),
|
|
@@ -9,6 +9,9 @@ export function detectDegreeFields(value: unknown): readonly DegreeField[];
|
|
|
9
9
|
export function extractRequiredExperienceYears(value: unknown): number | null;
|
|
10
10
|
export function hasUsWorkAuthorization(value: unknown): boolean;
|
|
11
11
|
export function requiresUsSponsorship(value: unknown): boolean | null;
|
|
12
|
+
// Shared with hiring-source-semantics.js's detectVisaSponsorshipWording.
|
|
13
|
+
export const SPONSORSHIP_NOT_OFFERED_RE: RegExp;
|
|
14
|
+
export const SPONSORSHIP_OFFERED_RE: RegExp;
|
|
12
15
|
export function isNoSponsorshipRequirement(value: unknown): boolean;
|
|
13
16
|
export function bucketVacancyText(value: unknown): Readonly<{ required: string; optional: string; context: string; noise: string }>;
|
|
14
17
|
export function classifyCvSectionHeading(value: unknown): CvSection | null;
|
|
@@ -70,9 +70,37 @@ export function requiresUsSponsorship(value) {
|
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Shared with hiring-source-semantics.js's detectVisaSponsorshipWording so the
|
|
74
|
+
// negative and positive sponsorship signals cannot drift into two competing
|
|
75
|
+
// implementations. SPONSORSHIP_OBJECT covers "visa support" as an alternative
|
|
76
|
+
// object to "sponsorship" (not just the latter) so "We do not offer work visa
|
|
77
|
+
// support" / "no work visa sponsorship provided" resolve the same way as
|
|
78
|
+
// "we do not offer visa sponsorship" — the modifier group tries "work visa "
|
|
79
|
+
// as a unit first and falls back (via normal regex backtracking) to "work "
|
|
80
|
+
// alone so "visa support" is still available as the object.
|
|
81
|
+
const SPONSORSHIP_OBJECT_MODIFIER = '(?:work\\s+visa\\s+|work\\s+|visa\\s+|immigration\\s+|employment\\s+)?';
|
|
82
|
+
const SPONSORSHIP_OBJECT = '(?:sponsorship|visa\\s+support)';
|
|
83
|
+
|
|
84
|
+
export const SPONSORSHIP_NOT_OFFERED_RE = new RegExp(
|
|
85
|
+
`(?:\\bno\\s+${SPONSORSHIP_OBJECT_MODIFIER}${SPONSORSHIP_OBJECT}\\b`
|
|
86
|
+
+ `|\\b(?:will\\s+not|cannot|can't|unable\\s+to|not\\s+able\\s+to)\\s+sponsor\\b`
|
|
87
|
+
+ `|\\b(?:does|do)\\s+not\\s+(?:offer|provide|support)\\s+(?:current\\s+or\\s+future\\s+)?${SPONSORSHIP_OBJECT_MODIFIER}${SPONSORSHIP_OBJECT}\\b`
|
|
88
|
+
+ `|\\bwithout\\s+(?:the\\s+need\\s+for\\s+)?(?:(?:current\\s+(?:and\\/or|or)\\s+future|current|future)\\s+)?(?:employer\\s+|visa\\s+)?sponsorship\\b`
|
|
89
|
+
+ `|\\bmust\\s+(?:be\\s+)?(?:legally\\s+)?authoriz\\w+\\s+to\\s+work[^.!?]{0,100}\\bwithout\\s+(?:current\\s+or\\s+future\\s+)?sponsorship\\b`
|
|
90
|
+
+ `|\\bmust\\s+not\\s+require\\s+(?:current\\s+or\\s+future\\s+)?(?:visa\\s+|employment\\s+)?sponsorship\\b`
|
|
91
|
+
+ `|\\b(?:current\\s+and\\/or\\s+future|current\\s+or\\s+future)\\s+sponsorship\\s+(?:is\\s+)?not\\s+(?:available|provided|offered)\\b`
|
|
92
|
+
+ `|\\bsponsorship\\s+(?:is\\s+)?not\\s+(?:available|provided|offered)\\b`
|
|
93
|
+
+ `|\\bno\\s+c2c(?:\\s+or\\s+visa\\s+sponsorship)?\\b`
|
|
94
|
+
+ `|\\bmay\\s+not\\s+be\\s+able\\s+to\\b[^\\n!?]{0,450}\\b(?:sponsor|support|provide)\\b[^\\n!?]{0,180}\\bsponsorship\\b`
|
|
95
|
+
+ `|\\b(?:will|can|may)\\s+not\\b[^\\n!?]{0,220}\\b(?:sponsor|support|provide)\\b[^\\n!?]{0,160}\\bsponsorship\\b`
|
|
96
|
+
+ `|\\bnot\\s+(?:currently\\s+)?(?:able\\s+to\\s+)?(?:sponsor|support|provide)\\b[^\\n!?]{0,160}\\bsponsorship\\b)`,
|
|
97
|
+
'iu',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const SPONSORSHIP_OFFERED_RE = /(?:\bwill\s+sponsor\b|\bwe\s+sponsor\b|\b(?:can|may)\s+sponsor\b|\bopen\s+to\s+(?:visa\s+)?sponsorship\b|\bvisa\s+sponsorship\s+(?:is\s+)?(?:available|provided|offered|possible)\b|\b(?:h-?1b|h1-b)\s+(?:visa\s+)?sponsorship\b|\bh-?1b\s+transfer\b|\bimmigration\s+sponsorship\b|\bemployment\s+visa\s+sponsorship\b|\bwork\s+visa\s+sponsorship\b|\bsponsor(?:ing)?\s+(?:qualified|eligible|selected)\s+candidates\b|\beligible\s+for\s+(?:visa\s+)?sponsorship\b|\bvisa\s+support\b|\bwork\s+visa\s+support\b)/iu;
|
|
101
|
+
|
|
73
102
|
export function isNoSponsorshipRequirement(value) {
|
|
74
|
-
|
|
75
|
-
return /(?:\bno\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\b(?:will\s+not|cannot|can't|unable\s+to|not\s+able\s+to)\s+sponsor\b|\b(?:does|do)\s+not\s+(?:offer|provide|support)\s+(?:current\s+or\s+future\s+)?(?:visa\s+|employment\s+)?sponsorship\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:current\s+or\s+future\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,100}\bwithout\s+(?:current\s+or\s+future\s+)?sponsorship\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bmay\s+not\s+be\s+able\s+to\b[^\n!?]{0,450}\b(?:sponsor|support|provide)\b[^\n!?]{0,180}\bsponsorship\b|\b(?:will|can|may)\s+not\b[^\n!?]{0,220}\b(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:sponsor|support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/i.test(text);
|
|
103
|
+
return SPONSORSHIP_NOT_OFFERED_RE.test(String(value || ''));
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
const REQUIRED_MARKER_RE = /\b(requirements?|qualifications?|minimum qualifications?|required skills?|must[- ]?have|you have|what (?:we|you) (?:are looking for|need|bring)|you(?:'|’)ll need|who you are|ideal candidate|what makes you a fit)\b|требован|квалификац|обязательн|необходим(?:о|ые|ый)|что мы (?:жд[её]м|ожидаем)|кого мы ищем|вимог|кваліфікац|обов['’]?язков|необхідн|кого ми шукаємо/i;
|
package/src/hiring-skills.js
CHANGED
|
@@ -7,7 +7,7 @@ export { escapeRegex } from './normalization.js'
|
|
|
7
7
|
|
|
8
8
|
// These canonical labels are ordinary words or one-letter tokens. Matching the
|
|
9
9
|
// label itself would create noisy results; only their explicit aliases are safe.
|
|
10
|
-
const AMBIGUOUS_CANONICALS = new Set(['C', 'Go', 'Make', 'REST', 'Spring'])
|
|
10
|
+
const AMBIGUOUS_CANONICALS = new Set(['C', 'Go', 'Make', 'REST', 'Spring', 'R'])
|
|
11
11
|
|
|
12
12
|
const group = (category, subcategory, entries) =>
|
|
13
13
|
entries.map(([name, aliases = []]) => ({
|
|
@@ -22,7 +22,7 @@ const group = (category, subcategory, entries) =>
|
|
|
22
22
|
export const SKILL_CATALOG = [
|
|
23
23
|
...group('IT', 'Frontend', [
|
|
24
24
|
['HTML', ['html5']], ['CSS', ['css3']], ['Sass', ['scss']], ['Less', ['less css']],
|
|
25
|
-
['JavaScript', ['ecmascript', 'es6', 'js developer', 'js framework']], ['TypeScript', ['type script']],
|
|
25
|
+
['JavaScript', ['ecmascript', 'es6', 'js developer', 'js framework', 'js']], ['TypeScript', ['type script', 'ts']],
|
|
26
26
|
['React', ['react.js', 'reactjs']], ['React Native', ['react-native']],
|
|
27
27
|
['Vue', ['vue.js', 'vuejs']], ['Nuxt', ['nuxt.js', 'nuxtjs']],
|
|
28
28
|
['Next.js', ['nextjs', 'next js']], ['Angular', ['angular.js', 'angularjs']],
|
|
@@ -95,6 +95,7 @@ export const SKILL_CATALOG = [
|
|
|
95
95
|
]),
|
|
96
96
|
...group('Data', 'Analytics & AI', [
|
|
97
97
|
['Data Analysis', ['analytics', 'data analytics', 'анализ данных']], ['Business Analytics'], ['Commercial Analytics'],
|
|
98
|
+
['R', ['r language', 'r programming', 'rstudio', 'r stats', 'tidyverse']],
|
|
98
99
|
['Pandas'], ['NumPy'], ['Jupyter'], ['Power BI', ['powerbi']], ['Tableau'], ['Looker'], ['Qlik'],
|
|
99
100
|
['Apache Spark', ['pyspark']], ['Hadoop'], ['Airflow'], ['Kafka'], ['RabbitMQ'], ['ETL'],
|
|
100
101
|
['Data Warehouse'], ['Data Science'], ['Machine Learning', ['машинное обучение']], ['Deep Learning'], ['TensorFlow'],
|
|
@@ -3,6 +3,7 @@ import { aliasesOf, escapeRegex, normalizeUnicode } from './normalization.js';
|
|
|
3
3
|
import { parseSalary } from './money.js';
|
|
4
4
|
import { extractCandidateName } from './hiring-candidate-fields.js';
|
|
5
5
|
import { countryCurrency } from './country-context.js';
|
|
6
|
+
import { SPONSORSHIP_NOT_OFFERED_RE, SPONSORSHIP_OFFERED_RE } from './hiring-requirements.js';
|
|
6
7
|
|
|
7
8
|
const FIELD_EXTRA_ALIASES = Object.freeze({
|
|
8
9
|
candidate: Object.freeze({
|
|
@@ -248,13 +249,12 @@ export function detectUsLocation(value) {
|
|
|
248
249
|
return /\bunited states\b|\busa\b|\bu\.s\.?\b|\bUS(?:\s+remote)?\b|\b(?:AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC)\b/i.test(String(value || ''));
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// The negative/positive regexes themselves live in hiring-requirements.js so
|
|
253
|
+
// this detector and isNoSponsorshipRequirement() cannot drift apart.
|
|
254
254
|
export function detectVisaSponsorshipWording(value) {
|
|
255
255
|
const text = String(value || '');
|
|
256
|
-
if (
|
|
257
|
-
if (
|
|
256
|
+
if (SPONSORSHIP_NOT_OFFERED_RE.test(text)) return 'notOffered';
|
|
257
|
+
if (SPONSORSHIP_OFFERED_RE.test(text)) return 'offered';
|
|
258
258
|
return null;
|
|
259
259
|
}
|
|
260
260
|
|
package/src/housing-address.d.ts
CHANGED
|
@@ -71,3 +71,14 @@ export function composeHousingAddress(parts?: Readonly<{
|
|
|
71
71
|
houseNumber?: unknown;
|
|
72
72
|
building?: unknown;
|
|
73
73
|
}>): string | null;
|
|
74
|
+
|
|
75
|
+
export type HousingAddressCandidate = Readonly<{
|
|
76
|
+
address: string | null;
|
|
77
|
+
street: string | null;
|
|
78
|
+
houseNumber: string | null;
|
|
79
|
+
building: string | null;
|
|
80
|
+
confidence: number;
|
|
81
|
+
score: number;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
export function extractHousingAddressCandidates(value: unknown): readonly HousingAddressCandidate[];
|
package/src/housing-address.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
matchTashkentHousingMetro,
|
|
5
5
|
matchTashkentNumberedArea,
|
|
6
6
|
} from './tashkent-housing-geography.js';
|
|
7
|
+
import { detectNonAddressSpans } from './semantic-spans.js';
|
|
7
8
|
|
|
8
9
|
const PHONE_RUN_RE = /\+?\d[\d\s().-]{7,}\d/gu;
|
|
9
10
|
const ADDRESS_LABEL_RE = /(?:адрес|адреса|адресація|адресация|manzil|address|adresă|adresa)\s*[:=\-–—]\s*/iu;
|
|
@@ -14,6 +15,11 @@ const HOUSE_MARKER = String.raw`(?:дом|д\.|будинок|буд\.|house|h\.
|
|
|
14
15
|
const BUILDING_MARKER = String.raw`(?:корп(?:ус)?\.?|к\.|строен(?:ие)?|стр\.|будова|секц(?:ия|ія)?|bloc|corp|building|bldg\.?|korpus|bino|bina|бино)`;
|
|
15
16
|
const NUMBER_TOKEN = String.raw`\d{1,5}(?:[-\/]?[\p{L}]\d{0,4})?(?:[\/-]\d{1,4}(?:[-\/]?[\p{L}]\d{0,4})?){0,2}`;
|
|
16
17
|
const STREET_WORD = String.raw`[\p{L}'’.-]{2,48}`;
|
|
18
|
+
// Common post-Soviet street names lead with a bare numeral ("8 Марта",
|
|
19
|
+
// "50 лет Октября"). It's only ever a prefix before the required letter
|
|
20
|
+
// word(s) below, never a substitute for them, so it cannot swallow a
|
|
21
|
+
// following bare house number on its own.
|
|
22
|
+
const LEADING_STREET_NUMERAL = String.raw`\d{1,3}`;
|
|
17
23
|
const SECONDARY_TOKEN = String.raw`(?:${NUMBER_TOKEN}|[\p{L}])`;
|
|
18
24
|
const LEVEL_NUMBER_TOKEN = String.raw`\d{1,3}(?:[-–—]?(?:й|ый|ий|st|nd|rd|th))?`;
|
|
19
25
|
const LEVEL_MARKER = String.raw`(?:этаж(?:е|у|ом)?|поверх(?:у|е|ом)?|floor|qavat(?:da)?|қабат(?:та)?|кават|қават|etaj(?:da|ul)?)`;
|
|
@@ -24,8 +30,13 @@ const PROPERTY_AREA_LINE_RE = /(?:^|[^\p{L}\p{N}_])(?:(?:общая|жилая|
|
|
|
24
30
|
const NON_ADDRESS_BARE_RE = /^(?:(?:(?:перш(?:ий|ому)|перв(?:ый|ом)|друг(?:ий|ому)|втор(?:ой|ом)|трет(?:ій|ьем|ий)|\d{1,3}(?:-?й)?)\s+(?:поверх|этаж|floor|qavat|қабат))|(?:поверх|этаж|floor|qavat|қабат)(?:\s|$)|(?:район|р-н|рн|мікрорайон|микрорайон|мкр\.?|жк|ж\.к\.|жилой\s+комплекс|житловий\s+комплекс|residential\s+complex)(?:\s|$)|(?:недалеко|поруч|рядом|біля|около|возле)(?=$|[^\p{L}\p{N}_])|(?:зупинка|остановка|станція|станция)(?:\s|$))/iu;
|
|
25
31
|
const DELIMITED_STREET_REJECT_RE = /(?:^|\s)(?:город|місто|city|район|р-н|рн|мікрорайон|микрорайон|мкр|жк|метро|поверх|этаж|floor|qavat|кімнат\p{L}*|комнат\p{L}*|квартира|квартири|квартиры|оренда|аренда|продаж\p{L}*|цена|ціна|площад\p{L}*|площа|зупинка|остановка|ориентир\p{L}*|ор[-–—]?р\.?)(?:\s|$)/iu;
|
|
26
32
|
const LOCATION_RELATION_RE = /(?:yonida|yaqin(?:ida)?|ro['’ʻʼ`]?parasida|near(?:by)?|close\s+to|next\s+to|рядом|возле|около|недалеко|поруч|біля|lângă|aproape)/iu;
|
|
33
|
+
const DESCRIPTIVE_MAHALLA_WORD_RE = /^(?:orqasidagi|yonidagi|yaqinidagi|oldidagi|ortidagi|nearby|behind|opposite)$/iu;
|
|
34
|
+
// "кв." also abbreviates "квадратный" (square, as in "кв. м" / square meters).
|
|
35
|
+
// A lone captured letter must not be "м"/"m" itself, or "площадь 45 кв. м"
|
|
36
|
+
// would misread the area unit as an apartment number.
|
|
37
|
+
const UNIT_LETTER_TOKEN = String.raw`(?!(?:м|m)(?:²|2)?(?![\p{L}\p{N}]))[\p{L}]`;
|
|
27
38
|
const UNIT_COMPONENT_PATTERNS = Object.freeze([
|
|
28
|
-
String.raw`(?:^|[\s,;])(?:кв\.?|кв-ра)(?!\p{L})\s*(?:№|#)?\s*(${
|
|
39
|
+
String.raw`(?:^|[\s,;])(?:кв\.?|кв-ра)(?!\p{L})\s*(?:№|#)?\s*(${NUMBER_TOKEN}|${UNIT_LETTER_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
29
40
|
String.raw`(?:^|[\s,;])квартира\s*(?:№|#)\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
30
41
|
String.raw`(?:^|[\s,;])(?:apt\.?|ap\.?|unit)(?!\p{L})\s*(?:no\.?|nr\.?|№|#)?\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
31
42
|
String.raw`(?:^|[\s,;])apartament(?:ul)?\s*(?:nr\.?|№|#)\s*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
|
|
@@ -73,6 +84,11 @@ function compactStreet(value) {
|
|
|
73
84
|
// "проживания" -> "оживания").
|
|
74
85
|
.replace(new RegExp(`^${PREFIX_STREET_MARKER}(?!\\p{L})\\s*`, 'iu'), '')
|
|
75
86
|
.replace(new RegExp(`\\s+${POSTFIX_STREET_MARKER}$`, 'iu'), '')
|
|
87
|
+
// A generic street-word capture has no stop-word list of its own, so a
|
|
88
|
+
// trailing relation marker ("недалеко", "рядом") from prose describing
|
|
89
|
+
// a *different* nearby location can get swept into the street name
|
|
90
|
+
// itself (e.g. "ул. Первого Мая недалеко"). Trim it and anything after.
|
|
91
|
+
.replace(new RegExp(`\\s+(?:${LOCATION_RELATION_RE.source})(?:\\s+.*)?$`, 'iu'), '')
|
|
76
92
|
// OCR frequently substitutes “оя” for the Ukrainian/Russian “ля” in
|
|
77
93
|
// “шлях”; correct the street-token typo before canonical lookup.
|
|
78
94
|
.replace(/(?<!\p{L})шоях(?!\p{L})/giu, 'шлях')
|
|
@@ -217,7 +233,11 @@ function tashkentGeoComponents(value) {
|
|
|
217
233
|
const district = matchTashkentHousingDistrict(text)?.name || null;
|
|
218
234
|
const metro = matchTashkentHousingMetro(text)?.name || null;
|
|
219
235
|
const mahalla = text.match(/(?:^|[^\p{L}])(\p{L}[\p{L}'’ʼ-]{1,48})\s+(?:mahalla(?:si)?|маҳалла(?:си)?|махалл[ая]|mfy)(?=$|[^\p{L}])/iu)?.[1] || null;
|
|
220
|
-
return Object.freeze({
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
district,
|
|
238
|
+
metro,
|
|
239
|
+
mahalla: DESCRIPTIVE_MAHALLA_WORD_RE.test(mahalla || '') ? null : compactStreet(mahalla),
|
|
240
|
+
});
|
|
221
241
|
}
|
|
222
242
|
|
|
223
243
|
function attachGeoComponents(parsed, value) {
|
|
@@ -360,7 +380,7 @@ function splitAddressTail(raw) {
|
|
|
360
380
|
|
|
361
381
|
function postfixTypedStreetAddress(line) {
|
|
362
382
|
const suffix = line.match(new RegExp(
|
|
363
|
-
`(?:^|[^\\p{L}\\p{N}])((?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
|
|
383
|
+
`(?:^|[^\\p{L}\\p{N}])((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD}\\s+${POSTFIX_STREET_TYPE})` +
|
|
364
384
|
`\\s*[,;]?\\s*(${NUMBER_TOKEN})` +
|
|
365
385
|
`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
|
|
366
386
|
`(?=$|[^\\p{L}\\p{N}])`,
|
|
@@ -384,7 +404,7 @@ function postfixTypedStreetAddress(line) {
|
|
|
384
404
|
function prefixTypedStreetAddress(line) {
|
|
385
405
|
const prefix = line.match(new RegExp(
|
|
386
406
|
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}\\s+` +
|
|
387
|
-
`((?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
|
|
407
|
+
`((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,4}${STREET_WORD})` +
|
|
388
408
|
`\\s*[,;]?\\s*(?:${HOUSE_MARKER}\\s*)?(${NUMBER_TOKEN})` +
|
|
389
409
|
`(?:\\s*[,;]?\\s*${BUILDING_MARKER}\\s*(${NUMBER_TOKEN}))?` +
|
|
390
410
|
`(?=$|[^\\p{L}\\p{N}])`,
|
|
@@ -410,11 +430,23 @@ function addressCandidateLine(line) {
|
|
|
410
430
|
const markerIndex = text.search(new RegExp(`${PREFIX_STREET_MARKER}|${POSTFIX_STREET_MARKER}`, 'iu'));
|
|
411
431
|
const searchStart = markerIndex >= 0 ? markerIndex : 0;
|
|
412
432
|
const tail = text.slice(searchStart);
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
433
|
+
const stopMatch = tail.match(ADDRESS_FIELD_STOP_RE);
|
|
434
|
+
const stopAt = stopMatch ? searchStart + (stopMatch.index ?? 0) : Infinity;
|
|
435
|
+
// A money/contact/temporal span overlapping the street phrase (e.g.
|
|
436
|
+
// "99 1881919", "100$ депозит", "от 1 месяца") is at least as strong
|
|
437
|
+
// evidence that this text belongs to another domain as the local
|
|
438
|
+
// stop-word list below — reuse the shared classifier instead of growing
|
|
439
|
+
// another ad-hoc stop-word list here for every new case found. The
|
|
440
|
+
// classifier already excludes calendar-date-shaped temporal spans
|
|
441
|
+
// ("8 Марта", "9 Января" — a real Soviet-legacy street-naming
|
|
442
|
+
// convention) from its TEMPORAL results, so this stays safe for those.
|
|
443
|
+
const nonAddressSpan = detectNonAddressSpans(text)
|
|
444
|
+
.find((span) => span.start >= searchStart && span.start < stopAt);
|
|
445
|
+
const cutAt = nonAddressSpan ? Math.min(stopAt, nonAddressSpan.start) : stopAt;
|
|
446
|
+
return cutAt < Infinity ? clean(text.slice(0, cutAt)) : line;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function collectExplicitStreetCandidates(text) {
|
|
418
450
|
const lines = text
|
|
419
451
|
.split(/[\r\n|]/u)
|
|
420
452
|
.map((part) => clean(part).slice(0, 1200))
|
|
@@ -443,7 +475,7 @@ function explicitStreetAddress(text) {
|
|
|
443
475
|
}
|
|
444
476
|
|
|
445
477
|
const boundedPrefix = line.match(new RegExp(
|
|
446
|
-
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}(?!\\p{L})\\s*((?:${STREET_WORD}\\s+){0,3}${STREET_WORD})(?=$|[,;])`,
|
|
478
|
+
`(?:^|[\\s,;])${PREFIX_STREET_MARKER}(?!\\p{L})\\s*((?:${LEADING_STREET_NUMERAL}\\s+)?(?:${STREET_WORD}\\s+){0,3}${STREET_WORD})(?=$|[,;])`,
|
|
447
479
|
'iu',
|
|
448
480
|
));
|
|
449
481
|
if (boundedPrefix) {
|
|
@@ -499,7 +531,7 @@ function explicitStreetAddress(text) {
|
|
|
499
531
|
// Listing text often names a nearby street before the actual postal
|
|
500
532
|
// address. Keep alternatives long enough to rank component evidence rather
|
|
501
533
|
// than returning whichever regex happened to run first.
|
|
502
|
-
return candidates
|
|
534
|
+
return Object.freeze(candidates
|
|
503
535
|
.map((value, index) => ({
|
|
504
536
|
value,
|
|
505
537
|
index,
|
|
@@ -507,7 +539,27 @@ function explicitStreetAddress(text) {
|
|
|
507
539
|
+ (value.houseNumber ? 0.18 : 0)
|
|
508
540
|
+ (value.building ? 0.03 : 0),
|
|
509
541
|
}))
|
|
510
|
-
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
542
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
543
|
+
.map(({ value, score }) => Object.freeze({ ...value, score: Number(score.toFixed(2)) })));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function explicitStreetAddress(text) {
|
|
547
|
+
const { score, ...winner } = collectExplicitStreetCandidates(text)[0] || {};
|
|
548
|
+
return winner.street ? Object.freeze(winner) : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Expose every plausible street/house candidate found in free-form listing
|
|
553
|
+
* text, ranked by evidence score, instead of only the single winner
|
|
554
|
+
* parseHousingAddress() commits to. Useful when the caller wants to inspect
|
|
555
|
+
* or re-rank competing parses (e.g. a nearby street mentioned before the
|
|
556
|
+
* actual postal address).
|
|
557
|
+
*/
|
|
558
|
+
export function extractHousingAddressCandidates(value) {
|
|
559
|
+
const text = clean(value);
|
|
560
|
+
if (!text) return Object.freeze([]);
|
|
561
|
+
const addressText = stripSecondaryComponents(text) || text;
|
|
562
|
+
return collectExplicitStreetCandidates(addressText);
|
|
511
563
|
}
|
|
512
564
|
|
|
513
565
|
function knownStreetAddress(text, knownStreet) {
|
|
@@ -612,7 +664,15 @@ function bareAddress(text) {
|
|
|
612
664
|
const cleaned = clean(text);
|
|
613
665
|
if (!cleaned || PROPERTY_AREA_LINE_RE.test(cleaned) || NON_ADDRESS_BARE_RE.test(cleaned)) return null;
|
|
614
666
|
const stopMatch = cleaned.match(ADDRESS_FIELD_STOP_RE);
|
|
615
|
-
const
|
|
667
|
+
const stopAt = stopMatch ? stopMatch.index : Infinity;
|
|
668
|
+
// allowBare trusts the caller's claim that this whole field is an
|
|
669
|
+
// address, so it has no street-marker anchor to lean on the way
|
|
670
|
+
// addressCandidateLine() does — a money/contact/temporal amount is the
|
|
671
|
+
// only guard against source data that mislabels e.g. a rental-duration
|
|
672
|
+
// field ("от 1 месяца") as an address field.
|
|
673
|
+
const nonAddressSpan = detectNonAddressSpans(cleaned).find((span) => span.start < stopAt);
|
|
674
|
+
const cutAt = nonAddressSpan ? Math.min(stopAt, nonAddressSpan.start) : stopAt;
|
|
675
|
+
const truncated = cutAt < Infinity ? clean(cleaned.slice(0, cutAt)) : cleaned;
|
|
616
676
|
if (!truncated) return null;
|
|
617
677
|
const tail = splitAddressTail(truncated);
|
|
618
678
|
if (!tail) return null;
|