@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.8.1",
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
- if (!/(?:shahri|город|viloyati|область|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
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 PHONE_CANDIDATE_RE = /\+?\d(?:[\t \u00a0().-]*\d){6,}(?:[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*\d{1,6})?/giu;
10
- const PHONE_EXTENSION_RE = /[\t \u00a0]*(?:ext\.?|extension|x|доб\.?|дод\.?)\s*(\d{1,6})$/iu;
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
- const TELEGRAM_USERNAME_RE = /^[A-Za-z0-9_]{5,32}$/;
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
- return PRICE_LABEL_BEFORE_NUMBER_RE.test(before);
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
- out += replacement;
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
- 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);
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
- entity('Oral', { kk: ['Орал'], ru: ['Уральск', 'Орал'], en: ['Oral', 'Uralsk'] }, { country: 'KZ' }),
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
- catalogCity('Alga', { kk: ['Алға'], ru: ['Алга'], en: ['Alga'] }, { country: 'KZ', priority: 'P4' }),
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
- catalogCity('Aral', { kk: ['Арал'], ru: ['Аральск', 'Арал'], en: ['Aral', 'Aralsk'] }, { country: 'KZ', priority: 'P4' }),
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
- catalogCity('Shu', { kk: ['Шу'], ru: ['Шу'], en: ['Shu'] }, { country: 'KZ', priority: 'P4' }),
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
- const country = COUNTRY_MATCHERS.find(({ re }) => re.test(text))?.item;
70
- if (country?.code) return country.code;
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
- const match = CITY_MATCHERS.find((matcher) =>
85
- (!code || matcher.item.country === code) && Boolean(cityTextMatch(text, matcher)));
86
- if (!match) return null;
87
- return Object.freeze({ canonical: match.item.canonical, country: match.item.country || null });
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
- entity('Kant', { ky: ['Кант'], ru: ['Кант'], en: ['Kant'] }, { country: 'KG', type: 'city' }),
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
- const SECTION_WEIGHT = Object.freeze({ experience: 1, projects: 0.7, profile: 0.55, skills: 0.4, education: 0.35, other: 0.45 });
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', 'для', 'что', 'как', 'или', 'это', 'мы', 'вы', 'ваш', 'ваша', 'ваши', 'наш', 'наша', 'наши', 'работа', 'работы', 'работать', 'опыт', 'лет', 'года', 'год', 'команда', 'команды', 'знание', 'знания', 'навыки', 'требования', 'обязанности', 'будет', 'нужно', 'необходимо', 'умение', 'разработка', 'разработки', 'позиция', 'кандидат']);
@@ -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
- 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: ['жұмыс істеуге рұқсат қажет'] }),
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
- const text = String(value || '');
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;
@@ -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
- const NEGATIVE_SPONSORSHIP_RE = /(?:\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|\bdo(?:es)?\s+not\s+(?:offer|provide)\s+(?:visa\s+|immigration\s+|employment\s+)?sponsorship\b|\bwithout\s+(?:the\s+need\s+for\s+)?(?:(?:current\s+(?:and\/or|or)\s+future|current|future)\s+)?(?:employer\s+|visa\s+)?sponsorship\b|\bmust\s+(?:be\s+)?(?:legally\s+)?authoriz\w+\s+to\s+work[^.!?]{0,80}\bwithout\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?sponsorship\b|\bmust\s+not\s+require\s+(?:current\s+or\s+future\s+|current\s+|future\s+)?(?:visa\s+|employment\s+)?sponsorship\b|\b(?:current\s+and\/or\s+future|current\s+or\s+future)\s+sponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bsponsorship\s+(?:is\s+)?not\s+(?:available|provided|offered)\b|\bno\s+c2c(?:\s+or\s+visa\s+sponsorship)?\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(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b|\bnot\s+(?:currently\s+)?(?:able\s+to\s+)?(?:support|provide)\b[^\n!?]{0,160}\bsponsorship\b)/iu;
252
- const POSITIVE_SPONSORSHIP_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;
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 (NEGATIVE_SPONSORSHIP_RE.test(text)) return 'notOffered';
257
- if (POSITIVE_SPONSORSHIP_RE.test(text)) return 'offered';
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
 
@@ -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[];
@@ -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*(${SECONDARY_TOKEN})(?=$|[^\p{L}\p{N}])`,
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({ district, metro, mahalla: compactStreet(mahalla) });
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 match = tail.match(ADDRESS_FIELD_STOP_RE);
414
- return match ? clean(text.slice(0, searchStart + (match.index ?? 0))) : line;
415
- }
416
-
417
- function explicitStreetAddress(text) {
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)[0]?.value || null;
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 truncated = stopMatch ? clean(cleaned.slice(0, stopMatch.index)) : cleaned;
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;