@whiteslove/parsing-lexicon 0.5.14 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -379,6 +379,7 @@ export * from './src/housing-title.js';
379
379
  export * from './src/housing-language.js';
380
380
  export * from './src/housing-features.js';
381
381
  export * from './src/housing-listing-fields.js';
382
+ export * from './src/housing-card-fields.js';
382
383
 
383
384
  // housing semantic helper declarations added in 0.2.5
384
385
  export function resolveHousingOccupancy(value: unknown): 'wholeProperty' | 'room' | 'sharedRoom' | 'bedSpace' | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.5.14",
3
+ "version": "0.6.0",
4
4
  "description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
5
5
  "repository": {
6
6
  "type": "git",
@@ -113,6 +113,10 @@
113
113
  "types": "./src/housing-listing-enrichment.d.ts",
114
114
  "import": "./src/housing-listing-enrichment.js"
115
115
  },
116
+ "./housing-card-fields": {
117
+ "types": "./src/housing-card-fields.d.ts",
118
+ "import": "./src/housing-card-fields.js"
119
+ },
116
120
  "./hiring": "./src/hiring.js",
117
121
  "./hiring-languages": "./src/hiring-languages.js",
118
122
  "./hiring-language-extensions": {
@@ -185,7 +189,11 @@
185
189
  "types": "./src/hiring-skills.d.ts",
186
190
  "import": "./src/hiring-skills.js"
187
191
  },
188
- "./hiring-advanced": "./src/hiring-advanced.js"
192
+ "./hiring-advanced": "./src/hiring-advanced.js",
193
+ "./hiring-safety": {
194
+ "types": "./src/hiring-safety.d.ts",
195
+ "import": "./src/hiring-safety.js"
196
+ }
189
197
  },
190
198
  "files": [
191
199
  "src",
@@ -0,0 +1,17 @@
1
+ export type HiringRiskCategory = 'gambling' | 'adult' | 'scam';
2
+
3
+ export interface HiringSuspicionResult {
4
+ riskCategory: HiringRiskCategory | null;
5
+ riskReasons: string[];
6
+ suspicious: boolean;
7
+ suspicionReasons: string[];
8
+ }
9
+
10
+ export declare function classifySuspicion(input: {
11
+ title?: string;
12
+ company?: string;
13
+ description?: string;
14
+ salaryMin?: number;
15
+ salaryMax?: number;
16
+ salaryCurrency?: string;
17
+ }): HiringSuspicionResult;
@@ -0,0 +1,361 @@
1
+ // Vacancy risk + vagueness classification.
2
+ // Hard-block only high-confidence industry/scam patterns; ambiguous signals stay soft.
3
+
4
+ // ---- Hard-blocked: gambling / iGaming -------------------------------------
5
+ const GAMBLING = [
6
+ ['casino', /\bcasino\b|казино|казіно|kazino/i],
7
+ ['gambling', /\bgambling\b|азартн(?:ые|ых|ой|і)\s*игр|азартні\s*ігри|qimor/i],
8
+ ['betting', /\bbetting\b|\bbookmaker\b|букмекер|беттинг|ставк[аи]\s+на\s+спорт|тотализатор/i],
9
+ ['igaming', /\bi-?gaming\b|\bslots?\s+(?:provider|studio|game)|\bpoker\s+(?:room|club|operator)/i],
10
+ ['betting-brand', /\b(?:1xbet|melbet|parimatch|betwinner|pin-?up|mostbet|1win)\b/i],
11
+ ];
12
+
13
+ // ---- Hard-blocked: adult / OnlyFans-adjacent ------------------------------
14
+ const ADULT_STRONG = [
15
+ ['onlyfans', /\bonly\s?fans\b|\bof-?модел|onlyfans-?модел/i],
16
+ ['webcam', /\bweb\s?cam\s?(?:model|studio)|вебкам|веб-?кам|webcam-?модел/i],
17
+ ['adult-content', /\badult\s+(?:content|industry|video|site)|порно|эротич|еротич|интим(?:н|ные услуги)|інтим/i],
18
+ ['escort', /\bescort\b|эскорт|ескорт/i],
19
+ ];
20
+ const ADULT_WEAK = /\bweb\s?model\b|веб-?модел|стример(?:ша|ов)?|стрімер|чат-?оператор|chat\s+operator|оператор\s+чат/i;
21
+ const ADULT_SIGNAL = /18\+|только\s+девушк|лише\s+дівчат|откровенн|відверт|приватн(?:ые|ый)\s+(?:шоу|чат)|интимн|adult|пикантн|без\s+интима|для\s+девушек\s+от\s+18/i;
22
+
23
+ // ---- Hard-blocked: earnings-bait / scam recruitment -----------------------
24
+ const SCAM = [
25
+ ['easy-money', /лёгк(?:ий|ие)\s+(?:заработок|деньги)|легк(?:ий|ие)\s+(?:заработок|деньги)|быстр(?:ый|ые)\s+(?:заработок|деньги)|easy\s+money|швидк(?:ий|і)\s+заробіт/i],
26
+ ['daily-payout', /выплаты\s+(?:ежедневно|каждый\s+день)|ежедневн(?:ые|ая)\s+выплат|оплата\s+каждый\s+день|щоденн[іа]\s+виплат/i],
27
+ ['guaranteed-income', /гарантированн(?:ый|ого)\s+доход|гарантований\s+дохід|guaranteed\s+income|доход\s+от\s+\d[\d\s]{2,}\s*(?:\$|usd|у\.?е\.?)\s*в\s*(?:день|неделю)/i],
28
+ ['no-investment', /без\s+вложений|без\s+вкладень|no\s+investment\s+required/i],
29
+ ['mlm', /сетев(?:ой|ого)\s+(?:маркетинг|бизнес)|\bmlm\b|млм|финансов(?:ая|ой)\s+независимост|пассивн(?:ый|ого)\s+доход/i],
30
+ ['crypto-bait', /гарантированн[а-яёіїєґ]*\s+(?:прибыл|профит)|трейдинг\s+с\s+гарант|инвестиц[а-яёіїєґ]*\s+с\s+гарант/i],
31
+ ];
32
+
33
+ const SCAM_CONTACTS = [
34
+ ['telegram:valery_hr_36', /(?:^|[^a-z0-9_])@?valery_hr_36(?:$|[^a-z0-9_])/i],
35
+ ['telegram:kris_mogelevich7', /(?:^|[^a-z0-9_])@?kris_mogelevich7(?:$|[^a-z0-9_])/i],
36
+ ['telegram:gasgazz_07', /(?:^|[^a-z0-9_])@?gasgazz_07(?:$|[^a-z0-9_])/i],
37
+ ['phone:+998992993435', /(?:\+?998[\s()-]*)99[\s()-]*299[\s()-]*34[\s()-]*35/],
38
+ ['phone:+998992600344', /(?:\+?998[\s()-]*)99[\s()-]*260[\s()-]*03[\s()-]*44/],
39
+ ['phone:+998931244802', /(?:\+?998[\s()-]*)93[\s()-]*124[\s()-]*48[\s()-]*02/],
40
+ ];
41
+
42
+ // Exact Telegram handles from reviewed external blacklists. User comments / "please
43
+ // check" submissions are deliberately excluded; only the editorial/list body is used.
44
+ const REPORTED_TELEGRAM_BY_SOURCE = {
45
+ moshelovka: [
46
+ 'pitupishka', 'obnalmanua1', 'p2p_lab_processing', 'hoodmoneyp2p', 'p2prvt',
47
+ 'protsessing', 'protsessing0', 'dropovod01k_chat', 'processing_skupka', 'mamonts',
48
+ 'brown_bear0', 'mediap2p', 'proseccina', 'amanatniy', 'pitupitradersrf',
49
+ 'russiantradersclubs', 'vvaybit',
50
+ ],
51
+ vklader: [
52
+ 'dobro_ot_yana', 'senpaj_help', 'shinobi_help', 'daime_helper', 'ninja_inform',
53
+ 'ninja_invest', 'assistent_ninja', 'alex_resolution', 'alex_crypto_way',
54
+ 'joecopytrade_bot', 'kirill_onchain', 'cryptolnspect', 'ghostl1', 'slashl1',
55
+ 'alexandrzenin', 'seriy_crypt', 'airolejon', 'olekitka', 'poizonrider',
56
+ 'poizonriderrobot', 'riderfeedback', 'poizonridersupport', 'poizondaniel',
57
+ 'poizonfenix', 'poizonsector', 'poizonlevel', 'poizonline', 'poizongo',
58
+ 'poizonoffice', 'poizonnation', 'poizonstorm', 'poizonsystem', 'poizonaura',
59
+ 'islyam_t', 'ethio_adam', 'vitalikadminn', 'ilya_vias', 'taddypedy', 'happyroman',
60
+ 'maxhappyict', 'rrrrviprr_bot', 'artem1991v', 'samuray_new', 'gen_zemtsov',
61
+ 'shortist_owner', 'arbitrage_capital', 'vladimir_arbitrage', 'mkwaydq',
62
+ 'poslednii_chance', 'romantradee', 'white_voronbtc', 'white_crow_btc',
63
+ 'unilive_network', 'potokcash', 'cashflowfund', 'potokpoint', 'cash_potok',
64
+ 'cashflowtime', 'kate_559', 'capitalforward', 'allocation_ay', 'andraicrypto',
65
+ 'andraicrypto_manager', 'vladbelokrylov', 'mersedes1_1', 'vadim_hub', 'david_gg7',
66
+ 'konstantinpravda', 'marafondeadinrich_bot', 'maratwhale', 'marat_whale',
67
+ 'fadeev_trade', 'andreysrbrv', 'speculant_g', 'sniperusdt', 'ratner_official',
68
+ 'arthurratner', 'markglavnyy', 'savivoin', 'l1r1q', 'arturomega', 'rodioncrypt0',
69
+ 'vipbyrodion_bot', 'magistr_tr', 'maxcrypto_adm', 'snipervip0001_bot', 'denis_longist',
70
+ 'alexeyaltador', 'anton_manag', 'twotradeowner', 'alexey_maker', 'alex_profitmaker',
71
+ 'hivetrader', 'robert_crypto98', 'neesmshnyi_bot', 'crypto_compass_btc', 'ska1pgod',
72
+ 'ternov_alexi', 'ternovhellobot', 'rafael_markov', 'cap_scalperr', 'dmitrukotov',
73
+ 'dmitrukot', 'crypto_partners_bot', 'learnarb_crypto', 'alinnainvest', 'speculyantt',
74
+ 'alex1trader', 'alexodessa_invest', 'alexodessa', 'maxbrotrd', 'mbro_pocket',
75
+ 'sergosnova', 'seedwalletshop_bot', 'seedpkultrasbot', 'arbitrageeproc', 'lebed_off',
76
+ 'cryptobotarbitrage_bot', 'youmentor_anna', 'kowalrenata', 'lunosupportstradebot',
77
+ 'nikicrypto_stre', 'exmonftmarket_bot', 'pr1vatee_roman', 'roman_pr1vat', 'rich_dmitry',
78
+ 'rudi_dmitry', 'sd_0986_bot', 'igor_richman', 'alex_wise_trade', 'alex_wiseman',
79
+ 'brokertribunai', 'tradelab_bot', 'tradelab_channel', 'tradelab_community',
80
+ 'superrare_thebot', 'traderr_server', 'trader_serverr', 'trader_servers',
81
+ 'traderer_stock', 'tanyamikheeva_pro_dengi', 'tanyamikheeva', 'veraastroguide',
82
+ 'daryu_money', 'mur_anastasi_official', 'dengisvoim_bot', 'minaevosnova',
83
+ 'pumpdumpcrypto_bot', 'trader_servver', 'traderr_serverl', 'maximonchain',
84
+ 'crypro_objectt', 'litvinov_teach', 'andrey_onchain', 'bank_tbx_bot', 'crypto_objectt',
85
+ ],
86
+ };
87
+
88
+ const REPORTED_TELEGRAM_INVITES = [
89
+ ['moshelovka:invite-hqnj', 't.me/+-hqnjkgsgha2zwm0'],
90
+ ['moshelovka:invite-adhp', 't.me/+adhpbvvjddk2njfi'],
91
+ ['vklader:penguin-protocol', 't.me/+jqffw2xgl04wytux'],
92
+ ['vklader:velvethaze', 't.me/joinchat/57q3oqqv_ea2njhi'],
93
+ ['vklader:pump', 't.me/+6ddntu5hsuo3ntmy'],
94
+ ['vklader:bullvault', 't.me/joinchat/5ll_t_cthgbhytri'],
95
+ ['vklader:lumencap', 't.me/joinchat/dbmlm5ieytezmwji'],
96
+ ['vklader:cryptoosnova', 't.me/joinchat/zylzt2q8gmc1mjyy'],
97
+ ];
98
+
99
+ const REPORTED_TELEGRAM_INDEX = (() => {
100
+ const index = new Map();
101
+ for (const [source, handles] of Object.entries(REPORTED_TELEGRAM_BY_SOURCE)) {
102
+ for (const handle of handles) {
103
+ const key = handle.toLowerCase();
104
+ const current = index.get(key) || [];
105
+ current.push(source);
106
+ index.set(key, current);
107
+ }
108
+ }
109
+ return index;
110
+ })();
111
+
112
+ function reportedTelegramReasons(text) {
113
+ const reasons = new Set();
114
+ const handles = new Set();
115
+ for (const m of text.matchAll(/(?:^|[^a-z0-9_])@([a-z0-9_]{5,})/gi)) handles.add(m[1].toLowerCase());
116
+ for (const m of text.matchAll(/(?:https?:\/\/)?t\.me\/([a-z0-9_]{5,})/gi)) handles.add(m[1].toLowerCase());
117
+
118
+ for (const handle of handles) {
119
+ for (const source of REPORTED_TELEGRAM_INDEX.get(handle) || []) {
120
+ reasons.add(`${source}:${handle}`);
121
+ }
122
+ }
123
+
124
+ const lower = text.toLowerCase();
125
+ for (const [reason, fragment] of REPORTED_TELEGRAM_INVITES) {
126
+ if (lower.includes(fragment)) reasons.add(reason);
127
+ }
128
+ return [...reasons];
129
+ }
130
+
131
+ // ---- Dating / romance-scam recruitment ------------------------------------
132
+ const DATING_AGENCY = /брачн(?:ое|ого|ом|ые|ых)\s+агентств|агентств[оа]\s+знакомств|шлюбн(?:е|ого|ому|і)\s+агентств|агенц(?:ія|ії)\s+знайомств|\bmarriage\s+agency\b|\bdating\s+agency\b/i;
133
+ const DATING_CHAT_ROLE = /чат-?оператор|оператор\s+чат|оператор\s+переписк|менеджер\s+переписк|переводчик\s+(?:в|для)\s+(?:чат|переписк)|correspondence\s+(?:operator|manager)|chat\s+operator|dating\s+operator/i;
134
+ const DATING_CHAT_SIGNAL = /переписк[а-яёіїєґ]*\s+от\s+лиц[ао]\s+(?:девуш|женщин|клиент)|вести\s+(?:женск[а-яёіїєґ]*\s+)?анкет|ведение\s+(?:женск[а-яёіїєґ]*\s+)?анкет|анкет[а-яёіїєґ]*\s+девуш|общени[а-яёіїєґ]*\s+с\s+(?:мужчин|иностранц)|спілкуван[а-яёіїєґ]*\s+з\s+(?:чоловік|іноземц)|листа[а-яёіїєґ]*\s+від\s+імені|писать\s+письма\s+(?:мужчинам|иностранцам)|подарк[а-яёіїєґ]*\s+от\s+(?:мужчин|клиент)|letters?\s+on\s+behalf\s+of|chat\s+on\s+behalf\s+of/i;
135
+
136
+ // ---- Paid spam / microtask recruitment ------------------------------------
137
+ const PAID_MICROTASK = /оплат[а-яёіїєґ]*\s+(?:за\s+)?(?:кажд[а-яёіїєґ]*|одно|один)\s+(?:сообщени|лайк|комментари|пост|публикаци|рассылк|действи)|(?:платим|платят|заработок)\s+за\s+(?:сообщени|лайк|комментари|пост|публикаци|рассылк|действи)|оплата\s+за\s+(?:сообщени|лайк|комментари|пост|публикаци|рассылк|действи)|paid\s+per\s+(?:message|like|comment|post|task|action)|payment\s+per\s+(?:message|like|comment|post|task|action)/i;
138
+ const MASS_SPAM_ACTION = /массов[а-яёіїєґ]*\s+рассылк|рассыл[а-яёіїєґ]*\s+(?:сообщени|текст|объявлен)|отправ[а-яёіїєґ]*\s+сообщени[а-яёіїєґ]*\s+(?:в|по)\s+(?:групп|чат)|размещ[а-яёіїєґ]*\s+(?:текст|сообщени|объявлен|пост)[а-яёіїєґ]*\s+(?:в|по)\s+(?:групп|чат)|публик[а-яёіїєґ]*\s+(?:в|по)\s+(?:групп|чат)|(?:facebook|telegram|whatsapp)\s+(?:groups?|chats?)|post\w*\s+(?:in|to)\s+(?:facebook|telegram|whatsapp)?\s*(?:groups?|chats?)/i;
139
+ const SCREENSHOT_PROOF = /скриншот[а-яёіїєґ]*\s+(?:как\s+)?(?:подтверждени|отч[её]т|доказательств)|подтвержд[а-яёіїєґ]*\s+(?:выполнени[а-яёіїєґ]*\s+)?скриншот|присл[а-яёіїєґ]*\s+скриншот|screenshot\s+(?:as\s+)?(?:proof|confirmation|report)|send\s+(?:a\s+)?screenshot/i;
140
+
141
+ // ---- Common fake-job schemes documented by T—Ж / Cyberpolice -------------
142
+ const PAY_TO_WORK = /(?:нужно|необходимо|потребуется|требуется|просим|перед\s+началом)[^.\n]{0,120}(?:оплатить|внести|перевести|пополнить)[^.\n]{0,120}(?:обучени|курс|страхов|доступ\s+к\s+(?:работ|задани)|активац|регистрац|подписк|софт|программ|оформлени|документ|взнос)|(?:платн[а-яёіїєґ]*\s+(?:обучени|стажировк|доступ\s+к\s+работ))[\s\S]{0,160}(?:до|перед|для)\s+(?:начал|допуск|работ)/i;
143
+ const MARKETPLACE_BUYOUT = /(?:обратн[а-яёіїєґ]*\s+выкуп|выкуп(?:ать|ите|ить)?\s+(?:товар|заказ)|оплатить\s+(?:товар|заказ)|купить\s+товар\s+(?:для|чтобы)\s+(?:отзыв|задани))[\s\S]{0,240}(?:верн[её]м|возмест|возврат|комисс|процент|заработ)|(?:маркетплейс|ozon|wildberries|вайлдберриз|яндекс\s*маркет)[\s\S]{0,220}(?:выкуп|оплатить\s+товар)[\s\S]{0,180}(?:комисс|возврат|верн[её]м)/i;
144
+ const MONEY_MULE = /(?:принимать|получать|зачислят)[^.\n]{0,120}(?:деньг|платеж|перевод)[^.\n]{0,120}(?:на\s+(?:свою|вашу(?:\s+личную)?|личную)\s+(?:карт|сч[её]т)|на\s+карту\s+сотрудник)[\s\S]{0,200}(?:переводить|пересылать|отправлять|распределять|снимать)[\s\S]{0,140}(?:%|процент|комисс|оплат)|(?:аренд[а-яёіїєґ]*\s+(?:карт|банковск[а-яёіїєґ]*\s+сч[её]т)|сдать\s+(?:карту|сч[её]т)\s+в\s+аренд)/i;
145
+ const SIMPLE_TASK_DEPOSIT = /(?:лайк|отзыв|відгук|скриншот|брон(?:ировать|ювання)|просмотр[а-яёіїєґ]*\s+видео|прост[а-яёіїєґ]*\s+задани)[\s\S]{0,400}(?:внести|перевести|оплатить|пополнить)[^.\n]{0,120}(?:сумм|депозит|залог|баланс|товар|заказ|бронь|доступ)|(?:сначала|спочатку)[^.\n]{0,120}(?:платим|выплачиваем|винагород)[\s\S]{0,400}(?:потом|далее|потім)[^.\n]{0,160}(?:внести|перевести|оплатить|пополнить)/i;
146
+ const BANK_SECRET_REQUEST = /(?:(?:пришл|сообщ|укаж|введ|отправ)[\s\S]{0,100})?(?:cvv|cvc|pin-?код|пін-?код|код\s+из\s+sms|код\s+з\s+sms|логин\s+и\s+пароль\s+(?:от|до)\s+(?:онлайн|интернет)[-\s]?банк|пароль\s+(?:от|до)\s+(?:онлайн|интернет)[-\s]?банк)(?:[\s\S]{0,100}(?:пришл|сообщ|укаж|введ|отправ))?/i;
147
+
148
+ // ---- Existing scam/MLM templates ------------------------------------------
149
+ const INFOPRODUCT_RESELL = /перепродават[а-яёіїєґ]*\s+(?:наш[а-яёіїєґ]*\s+)?(?:курс|проект)|\brich\s*team\b|онлайн[-\s]?школ[а-яёіїєґ]*\s+RT\b|\bFRLNS\s*TEAM\b/i;
150
+ const MLM_TEAM_COMMISSION = /созда(?:ть|вайте|ни[ея])[а-яёіїєґ\s]*команд[а-яёіїєґ]*[^.\n]{0,120}(?:процент|%)[^.\n]{0,80}(?:товарооборот|оборот)|(?:процент|%)[^.\n]{0,80}(?:товарооборот|оборот)[^.\n]{0,120}команд|партн[её]рск[а-яёіїєґ]*\s+(?:структур|команд)[а-яёіїєґ]*[^.\n]{0,100}(?:доход|заработ)/i;
151
+ const VAGUE_REMOTE_EARNINGS = /научу\s+как\s+зарабат[а-яёіїєґ]*|научим\s+зарабат[а-яёіїєґ]*|зарабат[а-яёіїєґ]*\s+не\s+выходя\s+из\s+дома|желани[а-яёіїєґ]*\s+зарабат[а-яёіїєґ]*[^.\n]{0,100}(?:особых\s+навыков\s+не\s+требуется|опыт\s+не\s+нужен)/i;
152
+ const VAGUE_REMOTE_PROFILE = /особых\s+навыков\s+(?:в\s+работе\s+)?не\s+требуется|без\s+опыта|опыт\s+не\s+нужен|(?:девушк|женщин)[а-яёіїєґ\s,]*(?:от\s+)?\d{2}[\s–—-]*(?:до\s+)?\d{2}|(?:ПК|компьютер)[^.\n]{0,80}(?:интернет|доступ\s+в\s+интернет)/i;
153
+ const WELLNESS_NETWORK_PROJECT = /(?:женщин[а-яёіїєґ]*\s*30\+|женщин[а-яёіїєґ]*\s+в\s+декрет|мам[а-яёіїєґ]*\s+в\s+декрет)[\s\S]{0,500}(?:онлайн[-\s]?проект|сообществ[а-яёіїєґ]*\s+поддержк)[\s\S]{0,500}(?:здоровь|продукц[а-яёіїєґ]*\s+для\s+здоровь)[\s\S]{0,500}(?:доход|зарабат|бесплатн[а-яёіїєґ]*\s+обучени)/i;
154
+
155
+ // ---- Vague crypto recruitment ---------------------------------------------
156
+ const CRYPTO_JOB_SIGNAL = /цифров(?:ые|ых|ыми)\s+актив|криптовалют(?:а|ы|е|ой|ные|ных|ными)|крипто-?актив|\bDEX\b|\bDeFi\b|digital\s+assets?|crypto(?:currency)?\s+(?:services?|assets?|operations?)/i;
157
+ const VAGUE_CRYPTO_DUTIES = /работа\s+с\s+(?:предоставленн[а-яёіїєґ]*\s+)?информаци[а-яёіїєґ]*|работа\s+с\s+DEX-?инструмент[а-яёіїєґ]*|выполнени[а-яёіїєґ]*\s+(?:поставленных\s+)?задач\s+по\s+готов[а-яёіїєґ]*\s+алгоритм[а-яёіїєґ]*|использовани[а-яёіїєґ]*\s+(?:необходимых\s+)?криптовалютн[а-яёіїєґ]*\s+сервис[а-яёіїєґ]*(?:\s+(?:согласно|по)\s+(?:рабочим\s+)?инструкц[а-яёіїєґ]*)?|проверка\s+данных\s+и\s+статус[а-яёіїєґ]*\s+задач|сопровождени[а-яёіїєґ]*\s+(?:текущих\s+)?процесс[а-яёіїєґ]*|контроль\s+выполнени[а-яёіїєґ]*\s+(?:поставленных\s+)?задан[а-яёіїєґ]*|ведение\s+(?:внутренн[а-яёіїєґ]*\s+отч[её]тност|рабоч[а-яёіїєґ]*\s+данн[а-яёіїєґ]*)|соблюдени[а-яёіїєґ]*\s+инструкц[а-яёіїєґ]*|сопровожда(?:ть|ете|ете\s+их)[^.\n]{0,100}(?:операц|сделк)|следи(?:ть|те)[^.\n]{0,80}(?:параметр|операц|сделк)|готов[а-яёіїєґ]*\s+торгов[а-яёіїєґ]*\s+сигнал|working\s+with\s+(?:information|data)\s+(?:in|about)\s+(?:crypto|digital\s+assets)|follow(?:ing)?\s+(?:internal\s+)?instructions\s+for\s+crypto/i;
158
+ const BEGINNER_TRAINING_BAIT = /без\s+опыта|опыт[а-яёіїєґ\s]*не\s+(?:является\s+)?обязател|бесплатн[а-яёіїєґ]*\s+обучени|обучени[а-яёіїєґ]*\s+с\s+нуля|помощ[а-яёіїєґ]*\s+наставник|поддержк[а-яёіїєґ]*\s+(?:наставник|после\s+обучения)|no\s+experience|free\s+training|training\s+from\s+scratch|mentor(?:ship)?\s+(?:provided|available)/i;
159
+ const TELEGRAM_RECRUITMENT = /(?:обращаться|писать|контакт|подробност[а-яёіїєґ]*|связ[а-яёіїєґ]*)[^\n]{0,80}(?:telegram|телеграм)|(?:telegram|телеграм)[^\n]{0,80}(?:@?[a-z][a-z0-9_]{4,}|t\.me\/)/i;
160
+
161
+ // ---- Foreign-employment scams / Uzbekistan licensing ----------------------
162
+ const FOREIGN_JOB_SIGNAL = /работ[а-яёіїєґ]*\s+(?:за\s+границ|за\s+кордон|за\s+рубеж)|трудоустройств[а-яёіїєґ]*\s+(?:за\s+границ|за\s+рубеж)|працевлаштуван[а-яёіїєґ]*\s+за\s+кордон|work\s+abroad|overseas\s+employment/i;
163
+ const FOREIGN_EMPLOYMENT_INTERMEDIARY = /агентств[а-яёіїєґ]*\s+(?:по\s+)?(?:трудоустройств|занятост)|посредник[а-яёіїєґ]*\s+(?:по\s+)?трудоустройств|подбор\s+работ[а-яёіїєґ]*\s+за\s+(?:границ|рубеж)|помощ[а-яёіїєґ]*\s+(?:с\s+)?(?:трудоустройств|оформлени[а-яёіїєґ]*\s+(?:рабоч[а-яёіїєґ]*\s+)?виз)|employment\s+agency|recruitment\s+agency/i;
164
+ const FOREIGN_JOB_UPFRONT_FEE = /(?:работ[а-яёіїєґ]*\s+(?:за\s+границ|за\s+кордон|за\s+рубеж)|трудоустройств[а-яёіїєґ]*\s+(?:за\s+границ|за\s+рубеж)|працевлаштуван[а-яёіїєґ]*\s+за\s+кордон)[\s\S]{0,600}(?:предоплат|внести|оплатить|перевести)[^.\n]{0,140}(?:виз|приглашени|запрошенн|документ|страхов|регистрац|бронь|гарантийн[а-яёіїєґ]*\s+взнос|услуг[а-яёіїєґ]*\s+агентств)/i;
165
+
166
+ // Current licensed private employment agencies in Uzbekistan.
167
+ // Snapshot of the official Migration Agency register, updated there on 2026-08-08.
168
+ // Absence is a SOFT signal only; direct foreign employers are not intermediaries.
169
+ const UZ_LICENSED_FOREIGN_EMPLOYMENT_AGENCIES = [
170
+ 'reiwa', 'migo overseas consulting', 'turon world cooperation', 'specialist group',
171
+ 'naimix', 'common sense', 'dhaef global', 'job maker', 'the best-staff', 'mir power',
172
+ 'horizon work', 'world wide immigration', 'the kasb', 'best globalize',
173
+ 'gotalent international', 'globalhr', 'international migration line', 'work expert',
174
+ 'viza master', 'diamor', 'fairness japan', 'getwork', 'garant immigration',
175
+ 'youth globe', 'jobbridge', 'aimjob', 'worknet', 'gilen jobs', 'interwork', 'oukaway',
176
+ 'bestwill', 'world bridge', 'gate', 'immigration service', 'visacentrum',
177
+ 'resurs export group', 'imkon', 'trust migration', 'united careers', 'talantum group',
178
+ 'skd man power', 'qadam global', 'workline', 'meros job', 'hr job start',
179
+ 'mora work group', 'jobex',
180
+ ];
181
+
182
+ function normalizeCompany(value) {
183
+ return value
184
+ .toLowerCase()
185
+ .replace(/[“”"'`«»]/g, ' ')
186
+ .replace(/\b(?:ooo|mchj|llc|сп\s+ооо)\b/gi, ' ')
187
+ .replace(/xususiy\s+bandlik\s+agentligi/gi, ' ')
188
+ .replace(/private\s+employment\s+agency/gi, ' ')
189
+ .replace(/[^a-z0-9а-яёіїєґ-]+/gi, ' ')
190
+ .replace(/\s+/g, ' ')
191
+ .trim();
192
+ }
193
+
194
+ function isLicensedUzForeignEmploymentAgency(company) {
195
+ const normalized = normalizeCompany(company);
196
+ if (!normalized) return false;
197
+ return UZ_LICENSED_FOREIGN_EMPLOYMENT_AGENCIES.some((name) => normalized.includes(name));
198
+ }
199
+
200
+ // ---- Soft signals ----------------------------------------------------------
201
+ const HAS_DUTIES = /обязанност|обов'?язк|responsibilit|what\s+you(?:'|’)?ll\s+(?:do|be\s+doing)|задачи|завдання|duties|your\s+role|чем\s+предстоит\s+заниматься|функционал|job\s+description|требования\s+к\s+задачам/i;
202
+ const HAS_PRODUCT = /продукт|product|платформ|сервис|сервіс|service|приложени|застосунок|\bapp\b|систем|клиентам\s+(?:банка|компании)|индустри|industry|проект|project/i;
203
+ const VAGUE_TITLE = /^(?:менеджер|специалист|спеціаліст|сотрудник|співробітник|оператор|консультант|помощник|помічник|ассистент|manager|specialist|operator|assistant|consultant|employee|staff)$/i;
204
+ const GENERIC_DUTY = /общение\s+с\s+клиентами|спілкування\s+з\s+клієнтами|работа\s+с\s+клиентами|communication\s+with\s+clients|прием\s+звонков|ответы\s+на\s+сообщения/i;
205
+ const PROFESSIONAL_DUTY_ACTION = /\b(?:develop|deploy|maintain|analy[sz]e|prepare|identify|improve|build|integrate|support|design|implement|manage|monitor|optimi[sz]e|create|lead|test|review|coordinate|deliver)(?:s|ed|ing)?\b/gi;
206
+ const EARNINGS_FOCUS = /(?:доход|заработок|заработная\s+плата|зарплата|дохід|заробіток|earnings|income)/gi;
207
+ const WORK_WORDS = /(?:задач|обязанн|проект|разработ|клиент|продукт|команд|опыт|навык|обов|розроб|досвід|навич|task|project|develop|team|skill|experience)/gi;
208
+
209
+ function testAll(rules, text) {
210
+ const hits = [];
211
+ for (const [name, re] of rules) if (re.test(text)) hits.push(name);
212
+ return hits;
213
+ }
214
+
215
+ /**
216
+ * Classifies a vacancy's risk category (hard-blocked gambling/adult/scam
217
+ * signals) and vagueness (soft "this reads like a low-effort or predatory
218
+ * posting" signals). Deterministic and network-free.
219
+ */
220
+ export function classifySuspicion(input) {
221
+ const title = (input.title || '').trim();
222
+ const company = (input.company || '').trim();
223
+ const description = (input.description || '').trim();
224
+ const text = `${title}\n${company}\n${description}`;
225
+
226
+ const riskReasons = [];
227
+ let riskCategory = null;
228
+
229
+ const gambling = testAll(GAMBLING, text);
230
+ if (gambling.length) {
231
+ riskCategory = 'gambling';
232
+ riskReasons.push(...gambling.map((r) => `gambling:${r}`));
233
+ }
234
+
235
+ const adultStrong = testAll(ADULT_STRONG, text);
236
+ const adultWeak = ADULT_WEAK.test(text) && ADULT_SIGNAL.test(text);
237
+ if (adultStrong.length || adultWeak) {
238
+ riskCategory = riskCategory || 'adult';
239
+ riskReasons.push(...adultStrong.map((r) => `adult:${r}`));
240
+ if (adultWeak) riskReasons.push('adult:role+explicit-signal');
241
+ }
242
+
243
+ const scam = testAll(SCAM, text);
244
+ const scamContacts = testAll(SCAM_CONTACTS, text);
245
+ const reportedScamTelegram = reportedTelegramReasons(text);
246
+ const datingAgency = DATING_AGENCY.test(text);
247
+ const datingChat = DATING_CHAT_ROLE.test(text) && DATING_CHAT_SIGNAL.test(text);
248
+ const paidSpamTask = PAID_MICROTASK.test(text) && (MASS_SPAM_ACTION.test(text) || SCREENSHOT_PROOF.test(text));
249
+ const payToWork = PAY_TO_WORK.test(text);
250
+ const marketplaceBuyout = MARKETPLACE_BUYOUT.test(text);
251
+ const moneyMule = MONEY_MULE.test(text);
252
+ const simpleTaskDeposit = SIMPLE_TASK_DEPOSIT.test(text);
253
+ const bankSecretRequest = BANK_SECRET_REQUEST.test(text);
254
+ const foreignJobUpfrontFee = FOREIGN_JOB_UPFRONT_FEE.test(text);
255
+ const infoproductResell = INFOPRODUCT_RESELL.test(text);
256
+ const mlmTeamCommission = MLM_TEAM_COMMISSION.test(text);
257
+ const vagueRemoteEarnings = VAGUE_REMOTE_EARNINGS.test(text)
258
+ && VAGUE_REMOTE_PROFILE.test(text)
259
+ && TELEGRAM_RECRUITMENT.test(text);
260
+ const wellnessNetworkProject = WELLNESS_NETWORK_PROJECT.test(text);
261
+
262
+ const cryptoTop = input.salaryMax ?? input.salaryMin;
263
+ const cryptoCurrency = String(input.salaryCurrency || '').toUpperCase();
264
+ const highBeginnerCryptoPay = cryptoTop !== undefined
265
+ && (((cryptoCurrency === 'EUR' || cryptoCurrency === 'USD') && cryptoTop >= 3000)
266
+ || (cryptoCurrency === 'GBP' && cryptoTop >= 2500));
267
+ const vagueCryptoRecruitment = CRYPTO_JOB_SIGNAL.test(text)
268
+ && VAGUE_CRYPTO_DUTIES.test(text)
269
+ && BEGINNER_TRAINING_BAIT.test(text)
270
+ && (TELEGRAM_RECRUITMENT.test(text) || highBeginnerCryptoPay);
271
+
272
+ if (
273
+ scam.length
274
+ || scamContacts.length
275
+ || reportedScamTelegram.length
276
+ || datingAgency
277
+ || datingChat
278
+ || paidSpamTask
279
+ || payToWork
280
+ || marketplaceBuyout
281
+ || moneyMule
282
+ || simpleTaskDeposit
283
+ || bankSecretRequest
284
+ || foreignJobUpfrontFee
285
+ || infoproductResell
286
+ || mlmTeamCommission
287
+ || vagueRemoteEarnings
288
+ || wellnessNetworkProject
289
+ || vagueCryptoRecruitment
290
+ ) {
291
+ riskCategory = riskCategory || 'scam';
292
+ riskReasons.push(...scam.map((r) => `scam:${r}`));
293
+ riskReasons.push(...scamContacts.map((r) => `scam:known-contact:${r}`));
294
+ riskReasons.push(...reportedScamTelegram.map((r) => `scam:reported-contact:${r}`));
295
+ if (datingAgency) riskReasons.push('scam:dating-agency');
296
+ if (datingChat) riskReasons.push('scam:dating-chat');
297
+ if (paidSpamTask) riskReasons.push('scam:paid-spam-task');
298
+ if (payToWork) riskReasons.push('scam:pay-to-work');
299
+ if (marketplaceBuyout) riskReasons.push('scam:marketplace-buyout');
300
+ if (moneyMule) riskReasons.push('scam:money-mule');
301
+ if (simpleTaskDeposit) riskReasons.push('scam:simple-task-deposit');
302
+ if (bankSecretRequest) riskReasons.push('scam:bank-credentials');
303
+ if (foreignJobUpfrontFee) riskReasons.push('scam:foreign-job-upfront-fee');
304
+ if (infoproductResell) riskReasons.push('scam:course-resell');
305
+ if (mlmTeamCommission) riskReasons.push('scam:mlm-team-commission');
306
+ if (vagueRemoteEarnings) riskReasons.push('scam:vague-remote-earnings');
307
+ if (wellnessNetworkProject) riskReasons.push('scam:wellness-network-project');
308
+ if (vagueCryptoRecruitment) riskReasons.push('scam:vague-crypto-recruitment');
309
+ }
310
+
311
+ const suspicionReasons = [];
312
+ const professionalDutyActions = description.match(PROFESSIONAL_DUTY_ACTION)?.length || 0;
313
+ // Direct-employer snippets frequently omit a "Responsibilities" heading but
314
+ // still enumerate concrete work. Do not turn a truncated, substantive card
315
+ // into two vagueness signals merely because that heading is absent.
316
+ const hasDuties = HAS_DUTIES.test(text)
317
+ || (description.length >= 140 && !VAGUE_TITLE.test(title) && professionalDutyActions >= 2);
318
+ const longEnough = description.length >= 200;
319
+
320
+ if (!hasDuties && description.length < 400) suspicionReasons.push('no-responsibilities');
321
+ if (VAGUE_TITLE.test(title)) suspicionReasons.push('vague-title');
322
+ if (!hasDuties && GENERIC_DUTY.test(text)) suspicionReasons.push('generic-duties');
323
+ if (!company || /^(?:компания|company|фирма|организация|работодатель|employer)$/i.test(company)) {
324
+ suspicionReasons.push('unclear-employer');
325
+ }
326
+ if (longEnough && !HAS_PRODUCT.test(text)) suspicionReasons.push('no-product-description');
327
+
328
+ // Uzbekistan: a local intermediary offering overseas employment should be in the
329
+ // Migration Agency's licensed register. Missing match is warning-only because a
330
+ // foreign employer may recruit directly and company names can be abbreviated.
331
+ const uzLocalContext = /(?:\+?998(?:\D|$)|ташкент|tashkent|toshkent|узбекистан|uzbekistan|самарканд|samarkand)/i.test(text);
332
+ const uzForeignIntermediary = uzLocalContext
333
+ && FOREIGN_JOB_SIGNAL.test(text)
334
+ && FOREIGN_EMPLOYMENT_INTERMEDIARY.test(text);
335
+ if (uzForeignIntermediary && !isLicensedUzForeignEmploymentAgency(company)) {
336
+ suspicionReasons.push('foreign-employment-license-unverified');
337
+ }
338
+ const normalizedCompany = normalizeCompany(company);
339
+ if (uzForeignIntermediary && (normalizedCompany.includes('nps personal') || normalizedCompany === 'etos')) {
340
+ suspicionReasons.push('foreign-employment-license-terminated');
341
+ }
342
+
343
+ const earnings = (text.match(EARNINGS_FOCUS) || []).length;
344
+ const work = (text.match(WORK_WORDS) || []).length;
345
+ if (earnings >= 3 && earnings > work) suspicionReasons.push('earnings-focused');
346
+
347
+ const HIGH = { USD: 5000, EUR: 5000, GBP: 4000, PLN: 20000, UAH: 120000, KZT: 1500000, UZS: 40000000, RUB: 400000 };
348
+ const cap = HIGH[String(input.salaryCurrency || '').toUpperCase()];
349
+ const top = input.salaryMax ?? input.salaryMin;
350
+ if (cap && top && top >= cap && !hasDuties) suspicionReasons.push('high-salary-no-duties');
351
+
352
+ return {
353
+ riskCategory,
354
+ riskReasons: [...new Set(riskReasons)],
355
+ suspicious: suspicionReasons.length >= 2
356
+ || suspicionReasons.includes('foreign-employment-license-unverified')
357
+ || suspicionReasons.includes('foreign-employment-license-terminated')
358
+ || riskCategory !== null,
359
+ suspicionReasons: [...new Set(suspicionReasons)],
360
+ };
361
+ }
@@ -0,0 +1,7 @@
1
+ export declare function parseHousingQuarterLabel(value: unknown): string | null;
2
+ export declare function parseHousingCardAmenities(value: unknown): readonly string[];
3
+ export declare function parseHousingNearbyMentions(value: unknown): readonly string[];
4
+ export declare function parseHousingNearbyShops(value: unknown): readonly string[];
5
+ export declare function dedupeHousingNearbyMentions(values: readonly unknown[] | null | undefined): string[];
6
+ export declare const HOUSING_LISTING_KEYWORD_TAGS: readonly (readonly [string, RegExp])[];
7
+ export declare function matchHousingListingKeywordTags(text: unknown): readonly string[];
@@ -0,0 +1,187 @@
1
+ import { deepFreeze } from './lexicon-core.js';
2
+ import { parseHousingAmenities } from './housing-text.js';
3
+ import { housingSemanticCanonical } from './housing-display.js';
4
+
5
+ const AMENITY_LABELS = Object.freeze({
6
+ dishwasher: 'Dishwasher',
7
+ separateRooms: 'Separate rooms',
8
+ washingMachine: 'Washing machine',
9
+ television: 'Television',
10
+ bedLinen: 'Bed linen',
11
+ towels: 'Towels',
12
+ });
13
+
14
+ const STREET_LIKE_PLACE_RE = /(?:^|[^\p{L}\p{N}_])(?:ko['’`ʼʻ]?cha(?:si)?|кўча(?:си)?|yo['’`ʼʻ]?l(?:i)?|yoli|street|road|avenue|улица|вулиця|дорога|шоссе|проспект)(?=$|[^\p{L}\p{N}_])/iu;
15
+
16
+ const SHOP_CHAINS = Object.freeze([
17
+ ['Korzinka', /korzinka|корзинка/iu],
18
+ ['Makro', /(?:^|[^\p{L}\p{N}_])makro(?=$|[^\p{L}\p{N}_])|макро/iu],
19
+ ['Havas', /(?:^|[^\p{L}\p{N}_])[хxh]avas(?=$|[^\p{L}\p{N}_])|хавас/iu],
20
+ ['Carrefour', /carrefour|карфур/iu],
21
+ ['ATB', /(?:^|[^\p{L}\p{N}_])(?:атб|atb)(?=$|[^\p{L}\p{N}_])/iu],
22
+ ['Klass', /(?:^|[^\p{L}\p{N}_])(?:klass|класс)(?=$|[^\p{L}\p{N}_])/iu],
23
+ ['Magnum', /magnum|магнум/iu],
24
+ ['Bravo', /(?:^|[^\p{L}\p{N}_])bravo(?=$|[^\p{L}\p{N}_])|браво/iu],
25
+ ['Metro C&C', /(?:^|[^\p{L}\p{N}_])metro\s*(?:cash|c\s*&\s*c|market)|метро\s*кэш/iu],
26
+ ]);
27
+
28
+ const PLACE_KINDS = Object.freeze([
29
+ ['Рынок', /(?:рынок|базар|bozor)\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24})/u],
30
+ ['ЗАГС', /(?:^|[^\p{L}\p{N}_])ЗАГС(?:\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24}(?:\s+район\p{L}*)?))?/u],
31
+ ['Парк', /парк\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24})/u],
32
+ ['Школа', /школа\s*(№\s*\d{1,4})/iu],
33
+ ['Клиника', /(?:клиника|поликлиника|больница)\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24})/u],
34
+ ['Стадион', /стадион\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24})/u],
35
+ ['Университет', /(?:университет|институт)\s+([A-ZА-ЯЁ][\p{L}\p{N}_'’-]{2,24})/u],
36
+ ]);
37
+
38
+ const NEARBY_BLOCK_RE = /(?:^|\n)[^\n]{0,30}?(?:что\s+(?:есть|находится)\s+р[яa]дом|р[яa]дом(?:\s+(?:есть|находятся|расположены|с\s+домом))?|непода[лл][её]ку|поблизости|в\s+шаговой\s+доступности|окружени[ие]|ориентир(?:ы|ами)?|yaqinida|atrofida|yonida|nearby|landmarks?)\s*[:—-]?\s*([^\n]{4,500})/iu;
39
+ const NEARBY_NOISE_RE = /^(?:и|а|в|на|у|до|от|все|вс[её]|есть|рядом|близко|недалеко|минут\p{L}*|пешком|транспорт|остановк\p{L}*|магазин|магазины|аптека|аптеки|садик|садики|всё\s+необходимое|развит\p{L}*)$/iu;
40
+ const NEARBY_FILLER_RE = /(?:инфраструктур\p{L}*|все\s+необходимо|вс[её]\s+рядом|шаговой\s+доступност|развитый\s+район)/iu;
41
+
42
+ function enumeratedNearby(text) {
43
+ const blocks = [...text.matchAll(new RegExp(NEARBY_BLOCK_RE.source, `${NEARBY_BLOCK_RE.flags}g`))]
44
+ .map((match) => match[1])
45
+ .filter(Boolean);
46
+ const seen = new Set();
47
+ const items = [];
48
+ for (const item of blocks.join(', ').split(/[,;•·|]+/).map((part) => part
49
+ .replace(/\s{2,}/g, ' ')
50
+ .replace(/^[\s\-–—.]+|[\s\-–—.!]+$/g, '')
51
+ .trim())) {
52
+ if (item.length < 3 || item.length > 45 || NEARBY_NOISE_RE.test(item) || NEARBY_FILLER_RE.test(item)) continue;
53
+ if (!/\p{L}{3,}/u.test(item)) continue;
54
+ const key = item.toLocaleLowerCase();
55
+ if (seen.has(key)) continue;
56
+ seen.add(key);
57
+ items.push(item);
58
+ if (items.length >= 14) break;
59
+ }
60
+ return items;
61
+ }
62
+
63
+ export function parseHousingQuarterLabel(value) {
64
+ const text = String(value || '');
65
+ if (!text) return null;
66
+ if (/(?:^|[^\p{L}\p{N}_])(?:глинк[аи]?|glinka)(?:$|[^\p{L}\p{N}_])/iu.test(text)) return 'Glinka';
67
+ const match = text.match(/(\d{1,3})\s*(?:-?\s*(?:chi|чи))?\s*[-\s]?\s*(?:квартал|кв-?л(?=$|[^\p{L}\p{N}_])|kvartal(?:i)?|квартил|kvartil|мкр(?=$|[^\p{L}\p{N}_])|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)/iu)
68
+ || text.match(/(?:квартал|kvartal(?:i)?|квартил|kvartil|мкр|микрорайон|массив|massiv|daha(?:si|dan)?|hudud|худуд)\s*[-№#]?\s*(\d{1,3})/iu)
69
+ || text.match(/(?:чиланзар|chilonzor|chilanzar)\s*[-№#]?\s*(\d{1,2})(?!\d)/iu);
70
+ if (match) {
71
+ const label = `${Number(match[1])} kvartal`;
72
+ return STREET_LIKE_PLACE_RE.test(label) ? null : label;
73
+ }
74
+ const centralBlock = text.match(/(?:^|[^\p{L}\p{N}_])(?:ц|c)\s*[-–]?\s*(\d{1,2})(?:$|[^\p{L}\p{N}_])/iu);
75
+ return centralBlock ? `C-${Number(centralBlock[1])}` : null;
76
+ }
77
+
78
+ export function parseHousingCardAmenities(value) {
79
+ const amenities = parseHousingAmenities(value)
80
+ .filter((name) => AMENITY_LABELS[name])
81
+ .map((name) => AMENITY_LABELS[name]);
82
+ return deepFreeze(amenities);
83
+ }
84
+
85
+ export function parseHousingNearbyMentions(value) {
86
+ const text = String(value || '');
87
+ if (!text) return deepFreeze([]);
88
+ const out = enumeratedNearby(text);
89
+ for (const [kind, pattern] of PLACE_KINDS) {
90
+ const match = text.match(pattern);
91
+ if (!match) continue;
92
+ const label = match[1] ? `${kind} ${match[1].trim()}` : kind;
93
+ if (out.some((item) => item.toLocaleLowerCase().includes(kind.toLocaleLowerCase()))) continue;
94
+ if (!out.includes(label)) out.push(label);
95
+ }
96
+ return deepFreeze(out.slice(0, 16));
97
+ }
98
+
99
+ const NEARBY_CATEGORY_ALIASES = [
100
+ ['park', ['park', 'парк']],
101
+ ['school', ['school', 'школа']],
102
+ ['market', ['market', 'рынок', 'ринок', 'базар']],
103
+ ['stadium', ['stadium', 'стадион', 'стадіон']],
104
+ ['clinic', ['clinic', 'клиника', 'клініка']],
105
+ ];
106
+
107
+ /**
108
+ * De-duplicates a raw list of nearby-place mentions: collapses generic terms
109
+ * ("park"/"парк"/"парки") onto their shared canonical identity, then drops a
110
+ * bare category name ("park") when a more specific mention of that category
111
+ * ("Central park") is already present.
112
+ */
113
+ export function dedupeHousingNearbyMentions(values) {
114
+ const unique = [];
115
+ const seen = new Set();
116
+ for (const raw of values || []) {
117
+ const value = String(raw || '').replace(/\s+/g, ' ').trim();
118
+ if (!value) continue;
119
+ const key = housingSemanticCanonical(value).toLocaleLowerCase();
120
+ if (seen.has(key)) continue;
121
+ seen.add(key);
122
+ unique.push(value);
123
+ }
124
+
125
+ const categoryForExact = (value) => {
126
+ const key = value.toLocaleLowerCase();
127
+ return NEARBY_CATEGORY_ALIASES.find(([, aliases]) => aliases.includes(key))?.[0] || null;
128
+ };
129
+ const hasSpecific = (category) => {
130
+ const aliases = NEARBY_CATEGORY_ALIASES.find(([name]) => name === category)?.[1] || [];
131
+ return unique.some((value) => {
132
+ const key = value.toLocaleLowerCase();
133
+ if (aliases.includes(key)) return false;
134
+ return aliases.some((alias) => key.startsWith(`${alias} `) || key.endsWith(` ${alias}`));
135
+ });
136
+ };
137
+
138
+ return unique.filter((value) => {
139
+ const category = categoryForExact(value);
140
+ return !category || !hasSpecific(category);
141
+ });
142
+ }
143
+
144
+ // EN / RO / RU / UA / UZ / KZ keyword patterns for deriving human-friendly
145
+ // listing-card tags from free-text title + description.
146
+ export const HOUSING_LISTING_KEYWORD_TAGS = Object.freeze([
147
+ ['furnished', /\b(furnished|mobilat|mebl|меблюванн|мебльован|с мебелью|обставлен|jihozlangan|jihozli|жиһаз)/i],
148
+ ['unfurnished', /\b(unfurnished|nemobilat|без мебел|без меблів|jihozsiz|жиһazsіz|жиһазсыз)/i],
149
+ ['renovated', /\b(renovat|euro ?renov|євроремонт|ремонт|отремонт|reamenajat|с ремонтом|ta'?mirlangan|remont|жөндел|жөндеу)/i],
150
+ ['new build', /\b(new build|bloc nou|constructie noua|новострой|новобуд|новостро|yangi qurilgan|novostroyka|жаңа құрыл)/i],
151
+ ['parking', /\b(parking|parcare|garaj|garage|гараж|парков|парко-?місц|avtoturargoh|avtomobil joyi|автотұрақ|көлік)/i],
152
+ ['balcony', /\b(balcony|balcon|балкон|лоджи|лоджі|balkon|балкон)/i],
153
+ ['elevator', /\b(elevator|lift|ліфт|лифт|lift|лифт)/i],
154
+ ['pets ok', /\b(pets? ?(allowed|ok)|se accepta animale|можно с животными|з тваринами|uy hayvon|жануар)/i],
155
+ ['no agency', /\b(no agency|fara intermediari|fără comision|без посредник|без агент|собственник|власник|від власника|vositachisiz|egasidan|иесінен)/i],
156
+ ['utilities included', /\b(utilities included|utilitati incluse|комунальні включ|коммунальн.*включ|kommunal)/i],
157
+ ['studio', /\b(studio|garsonier|студи[яї]|studiya)/i],
158
+ ['air conditioning', /\b(air ?condition|\ba\/?c\b|conditioner|кондиционер|кондиціонер|konditsioner|klimat|klima\b|aer condi[țt]ionat)/i],
159
+ ['microwave', /\b(microwave|микроволнов|мікрохвильов|mikroto'?lqinli|mikrovolnovka|cuptor cu microunde|СВЧ)/i],
160
+ ['dishwasher', /\b(dishwasher|посудомо|посудомийн|idish yuvish|idishyuvg|ma[șs]ina de sp[ăa]lat vase)/i],
161
+ ['washing machine', /\b(washing ?machine|стиральн(?:ая|ой) маш|пральн(?:а|ої) маш|kir yuvish|kir mashina|ma[șs]ina de sp[ăa]lat rufe)/i],
162
+ ['central heating', /\b(central heating|centrala termica|central[ăa]|центральн.*отопл|опаленн|isitish|жылу)/i],
163
+ ['pool', /\b(pool|piscina|бассейн|басейн|basseyn)/i],
164
+ ['negotiable', /\b(negotiable|negociabil|торг(?! центр)|можен торг|kelishilgan|kelishamiz|келісім)/i],
165
+ ['for rent', /\b(for rent|de inchiriat|inchiriere|оренда|аренда|сдам|сдаётся|здам|ijara|arenda|жалға)/i],
166
+ ['for sale', /\b(for sale|de vanzare|vanzare|продаж|продажа|продам|продаётся|sotiladi|sotuv|сатылады)/i],
167
+ ]);
168
+
169
+ export function matchHousingListingKeywordTags(text) {
170
+ const value = String(text || '').toLowerCase();
171
+ const tags = [];
172
+ for (const [tag, re] of HOUSING_LISTING_KEYWORD_TAGS) {
173
+ if (re.test(value)) tags.push(tag);
174
+ }
175
+ return deepFreeze(tags);
176
+ }
177
+
178
+ export function parseHousingNearbyShops(value) {
179
+ const text = String(value || '');
180
+ if (!text) return deepFreeze([]);
181
+ const out = SHOP_CHAINS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
182
+ const namedMall = text.match(/([A-Za-z][A-Za-z'’.&-]*(?:\s+[A-Za-z][A-Za-z'’.&-]*){0,3}\s+(?:mall|moll|молл))/iu)?.[1]?.trim();
183
+ if (namedMall && !out.includes(namedMall)) out.push(namedMall);
184
+ const shoppingCenter = text.match(/(?:трц|тц)\s+([A-Za-z0-9'’.-]{2,25}|[А-Яа-яЁё0-9'’.-]{2,25})/iu)?.[1]?.trim();
185
+ if (shoppingCenter && !out.includes(`ТРЦ ${shoppingCenter}`)) out.push(`ТРЦ ${shoppingCenter}`);
186
+ return deepFreeze(out);
187
+ }
@@ -1 +1,4 @@
1
1
  export declare function looksCommercialHousing(value: unknown): boolean;
2
+ export declare function looksParkingOnly(value: unknown): boolean;
3
+ export declare function isDirectOwner(value: unknown): boolean;
4
+ export declare function hasZeroCommissionSignal(value: unknown): boolean;
@@ -7,3 +7,30 @@ const COMMERCIAL_HOUSING_RE =
7
7
  export function looksCommercialHousing(value) {
8
8
  return value ? COMMERCIAL_HOUSING_RE.test(String(value)) : false;
9
9
  }
10
+
11
+ // A "parking space" mention is only commercial when the same text does not
12
+ // also name a residential unit (owners often list a parking spot alongside
13
+ // the flat itself, e.g. "квартира с парковочным местом").
14
+ const PARKING_OBJECT_RE = /(?:парко?мест[а-яёіїґ]*|парковочн[а-яёіїґ]*\s+мест[а-яёіїґ]*|машино[-\s]?мест[а-яёіїґ]*|мест[а-яёіїґ]*\s+(?:в|на)\s+(?:паркинг[а-яёіїґ]*|парковк[а-яёіїґ]*)|parking\s+(?:space|spot)s?)/iu;
15
+ const HOUSING_OBJECT_RE = /(?:квартир[а-яёіїґ]*|апартамент[а-яёіїґ]*|студи[яії][а-яёіїґ]*|будин[а-яіїґ]*|(?:^|[^\p{L}\p{N}_])дом(?:а|ом|у|ов)?(?=$|[^\p{L}\p{N}_])|жиль[а-яё]*|житл[а-яіїґ]*|flat\b|apartment\b|studio\b|house\b|xonadon\b|kvartira\b|apartament\b|garsonier[ăa]\b)/iu;
16
+
17
+ export function looksParkingOnly(value) {
18
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
19
+ if (!text || !PARKING_OBJECT_RE.test(text)) return false;
20
+ return !HOUSING_OBJECT_RE.test(text);
21
+ }
22
+
23
+ // Cross-language "no agency" / "direct from owner" signals.
24
+ const DIRECT_OWNER_RE = /(?:без\s+(?:макл(?:ер[а-яё]*)?|посредник[а-яё]*|ри[еэ]?лтор[а-яё]*|агент[а-яё]*)|от\s+(?:собственник[а-яё]*|хозяин[а-яё]*)|від\s+(?:власник[а-яіїґ]*|власниц[яії][а-яіїґ]*|господар[а-яіїґ]*)|без\s+(?:посередник[а-яіїґ]*|рі[єе]лтор[а-яіїґ]*|агент[а-яіїґ]*)|прямо\s+від\s+(?:власник[а-яіїґ]*|власниц[яії][а-яіїґ]*|господар[а-яіїґ]*)|(?:власник|власниця)\s+(?:зда[єе]|прода[єе])|no\s+(?:agency|broker|realtor|agent)|owner\s+direct|direct\s+from\s+(?:owner|landlord)|f(?:ă|a)r(?:ă|a)\s+(?:agen(?:ț|t)ie|intermediar\w*)|direct\s+(?:de\s+la\s+)?proprietar|makler\s*[- ]?siz|maklersiz|bez\s*makler(?:a|ov)?|bezmakler(?:a|ov)?|vositachi\s*[- ]?siz|vositachisiz|egasidan|uy\s+egasidan|делдалсыз|делдал\s*жоқ|иесінен|үй\s+иесінен)/iu;
25
+
26
+ // Cross-language "no commission" signals. Direct-owner language implies zero
27
+ // commission even without an explicit "no commission" phrase.
28
+ const EXPLICIT_ZERO_COMMISSION_RE = /(?:без\s+(?:комисси[а-яё]*|комісі[а-яіїґ]*|комиссионн[а-яё]*)|no\s+(?:commission|agency\s+fee|broker\s+fee|realtor\s+fee|agent\s+fee)|f(?:ă|a)r(?:ă|a)\s+comision|komissiya\s*[- ]?siz|komissiyasiz|комиссиясыз|комиссия\s*жоқ)/iu;
29
+
30
+ export function isDirectOwner(value) {
31
+ return Boolean(value) && DIRECT_OWNER_RE.test(String(value));
32
+ }
33
+
34
+ export function hasZeroCommissionSignal(value) {
35
+ return isDirectOwner(value) || (Boolean(value) && EXPLICIT_ZERO_COMMISSION_RE.test(String(value)));
36
+ }
@@ -8,4 +8,5 @@ export const HOUSING_DEAL_TYPES: readonly unknown[];
8
8
  export const HOUSING_ACTION_MAP: Readonly<Record<HousingAction, Readonly<{ listingKind: HousingListingKind; dealType: Exclude<HousingDealType, 'shortRent'> }>>>;
9
9
  export function resolveHousingIntent(value: unknown): HousingIntentResult | null;
10
10
  export declare function classifyHousingDealType(value: unknown): 'sale' | 'longRent' | 'shortRent' | null;
11
+ export declare function looksExplicitDailyRentalMention(value: unknown): boolean;
11
12
 
@@ -152,3 +152,12 @@ export function classifyHousingDealType(value) {
152
152
  return null;
153
153
  }
154
154
 
155
+ // A bare "сутки/суток" (day-rate) mention outranks a source's generic
156
+ // long-rent default, even when the rest of the text does not otherwise
157
+ // resolve to shortRent.
158
+ const EXPLICIT_SHORT_STAY_RE = /(?:^|[^\p{L}\p{N}_])сут(?:ки|ок)(?=$|[^\p{L}\p{N}_])/iu;
159
+
160
+ export function looksExplicitDailyRentalMention(value) {
161
+ return EXPLICIT_SHORT_STAY_RE.test(String(value || ''));
162
+ }
163
+
@@ -178,7 +178,7 @@ export function parseHousingAmenities(value) {
178
178
  if (!text) return Object.freeze([]);
179
179
  const amenities = [];
180
180
  if (/(?:посудомо|посудомийн|dishwasher|idish\s*yuvish|idishyuvg|ma[șs]ina de sp[ăa]lat vase)/iu.test(text)) amenities.push('dishwasher');
181
- if (/(?:комнат\p{L}*\s+раздельн|изолированн\p{L}*\s+комнат|separate\s+rooms?)/iu.test(text)) amenities.push('separateRooms');
181
+ if (/(?:комнат\p{L}*\s+раздельн|(?:раздельн|изолированн)\p{L}*\s+комнат|separate\s+rooms?)/iu.test(text)) amenities.push('separateRooms');
182
182
  if (/(?:кроват|\bbed\b|\bspalni\b|\bsplani\b|yotoq\s+karavot|ётоқ\s+каравот)/iu.test(text)) amenities.push('bed');
183
183
  if (/(?:стиральн\p{L}*\s+машин|washing\s+machine|kir\s*yuvish\s*mashin|kirmoshina|кир\s+ювиш\s+машина)/iu.test(text)) amenities.push('washingMachine');
184
184
  if (/(?:холодильник|refrigerator|fridge|muzlatgich|музлатгич|xolodilnik)/iu.test(text)) amenities.push('refrigerator');
package/src/index.js CHANGED
@@ -47,6 +47,7 @@ export * from './housing-text.js';
47
47
  export * from './housing-poi-extensions.js';
48
48
  export * from './housing-display.js';
49
49
  export * from './housing-listing-enrichment.js';
50
+ export * from './housing-card-fields.js';
50
51
  export * from './hiring.js';
51
52
  export * from './hiring-languages.js';
52
53
  export * from './hiring-context.js';
@@ -34,7 +34,7 @@ function normalizeCompactApostropheForMatch(value) {
34
34
  .trim();
35
35
  }
36
36
 
37
- const CYRILLIC_SEARCH_MAP = Object.freeze({
37
+ export const CYRILLIC_SEARCH_MAP = Object.freeze({
38
38
  а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
39
39
  и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
40
40
  с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',