From e752a563e2e1c33e112d0b80641e7d77327af8b1 Mon Sep 17 00:00:00 2001 From: Oscar Date: Thu, 11 Jun 2026 15:49:50 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20feat(src/hh/scraper):=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BA=D1=8D?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B9=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ♻️ chore(src/hh/handlers/region): очищает кэш вакансий при изменении региона ♻️ chore(src/hh/bot-commands): очищает кэш вакансий при сбросе запроса ♻️ chore(src/hh/handlers/settings): очищает кэш вакансий при изменении режима поиска ♻️ chore(src/hh/handlers/resume): очищает кэш вакансий при сохранении резюме --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/hh/bot-commands.ts | 37 ++++++++++++++++++- src/hh/handlers/apply.ts | 2 +- src/hh/handlers/settings.ts | 20 ++++++++++ src/hh/scraper.ts | 18 +++++---- src/hh/state.ts | 4 ++ src/hh/types.ts | 1 + 8 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260611121153_add_excluded_words/migration.sql diff --git a/prisma/migrations/20260611121153_add_excluded_words/migration.sql b/prisma/migrations/20260611121153_add_excluded_words/migration.sql new file mode 100644 index 0000000..3918e28 --- /dev/null +++ b/prisma/migrations/20260611121153_add_excluded_words/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "excludedWords" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f84aa9..42753f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model Settings { selectedResumeId String? searchMode String @default("text") area String? + excludedWords String? } model SkippedVacancy { diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 55ff8b8..a18cf02 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -8,7 +8,7 @@ import { handleSkipped, handleStatus } from './handlers/info.js' import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo, showResumeModeStep } from './handlers/onboarding.js' import { handleAreaPick, handleRegion } from './handlers/region.js' import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.js' -import { DEFAULT_PROMPT, handleAutoToggle, handleMax, handlePrompt, handleQuery, handleSearchModeToggle } from './handlers/settings.js' +import { DEFAULT_PROMPT, handleAutoToggle, handleExclusions, handleMax, handlePrompt, handleQuery, handleSearchModeToggle } from './handlers/settings.js' import { getState } from './state.js' import { BTN, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, buildFiltersKeyboard, buildSettingsKeyboard } from './ui.js' @@ -32,6 +32,7 @@ const MESSAGE_HANDLERS: Partial> = { [BTN.INFO]: async (chatId) => { await bot.sendMessage(chatId, 'ℹ️ Информация:', { reply_markup: INFO_REPLY_KEYBOARD }) }, [BTN.BACK]: async (chatId) => { await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) }, [BTN.REGION]: handleRegion, + [BTN.EXCLUSIONS]: handleExclusions, } const CALLBACK_HANDLERS: Record = { @@ -101,6 +102,21 @@ const CALLBACK_HANDLERS: Record = { state.maxPromptMessageId = null await bot.deleteMessage(chatId, messageId).catch(() => {}) }, + hh_keep_exclusions: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingExclusions = false + state.exclusionPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + }, + hh_clear_exclusions: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingExclusions = false + state.exclusionPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await prisma.settings.update({ where: { telegramId: chatId }, data: { excludedWords: null } }) + clearVacancyCache(chatId) + await bot.sendMessage(chatId, '🗑 Слова исключения очищены') + }, hh_keep_prompt: async (chatId, messageId) => { const state = getState(chatId) state.awaitingPrompt = false @@ -178,6 +194,7 @@ async function clearAwaitingState(chatId: number): Promise { state.queryPromptMessageId, state.maxPromptMessageId, state.promptPromptMessageId, + state.exclusionPromptMessageId, state.onboardingMsgId, ] state.awaitingEmail = false @@ -185,6 +202,7 @@ async function clearAwaitingState(chatId: number): Promise { state.awaitingQuery = false state.awaitingMax = false state.awaitingPrompt = false + state.awaitingExclusions = false state.onboardingStep = null state.onboardingMsgId = null state.loginMethodMsgId = null @@ -192,6 +210,7 @@ async function clearAwaitingState(chatId: number): Promise { state.queryPromptMessageId = null state.maxPromptMessageId = null state.promptPromptMessageId = null + state.exclusionPromptMessageId = null await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {}))) } @@ -241,7 +260,7 @@ export function registerHHCommands() { return } - const isAwaiting = state.awaitingEmail || state.awaitingPhone || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt || state.onboardingStep !== null + const isAwaiting = state.awaitingEmail || state.awaitingPhone || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt || state.awaitingExclusions || state.onboardingStep !== null const isMenuButton = Object.values(BTN).includes(msg.text as typeof BTN[keyof typeof BTN]) || msg.text.startsWith(BTN.SEARCH_MODE_TOGGLE) || msg.text.startsWith(BTN.REGION) @@ -351,6 +370,20 @@ export function registerHHCommands() { return } + if (state.awaitingExclusions) { + state.awaitingExclusions = false + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + if (state.exclusionPromptMessageId) { + await bot.deleteMessage(chatId, state.exclusionPromptMessageId).catch(() => {}) + state.exclusionPromptMessageId = null + } + const normalized = msg.text.split(',').map(w => w.trim()).filter(Boolean).join(', ') + await prisma.settings.update({ where: { telegramId: chatId }, data: { excludedWords: normalized || null } }) + clearVacancyCache(chatId) + await bot.sendMessage(chatId, normalized ? `🚫 Слова исключения: ${normalized}` : '🗑 Слова исключения очищены', { parse_mode: 'HTML' }) + return + } + if (state.awaitingPrompt) { state.awaitingPrompt = false await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) diff --git a/src/hh/handlers/apply.ts b/src/hh/handlers/apply.ts index 108f13e..fe91b1c 100644 --- a/src/hh/handlers/apply.ts +++ b/src/hh/handlers/apply.ts @@ -21,7 +21,7 @@ export async function handleApply(chatId: number): Promise { await bot.sendMessage(chatId, `🔄 Ищу вакансии ${searchLabel}...`, { reply_markup: APPLYING_REPLY_KEYBOARD }) applyToJobs( - { query: settings.searchQuery, maxApplies: settings.maxApplies, searchMode, resumeId: settings.selectedResumeId ?? undefined, area: settings.area ?? undefined }, + { query: settings.searchQuery, maxApplies: settings.maxApplies, searchMode, resumeId: settings.selectedResumeId ?? undefined, area: settings.area ?? undefined, excludedWords: settings.excludedWords ?? undefined }, { chatId, reporter }, ) .then(async (result) => { diff --git a/src/hh/handlers/settings.ts b/src/hh/handlers/settings.ts index a25aa38..90fbf00 100644 --- a/src/hh/handlers/settings.ts +++ b/src/hh/handlers/settings.ts @@ -82,6 +82,26 @@ export async function handlePrompt(chatId: number): Promise { state.promptPromptMessageId = msg.message_id } +export async function handleExclusions(chatId: number): Promise { + const state = getState(chatId) + state.awaitingExclusions = true + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const current = settings?.excludedWords ?? '' + const text = current + ? `🚫 Текущие слова исключения: ${escapeHtml(current)}\n\nВведи новые через запятую или очисти:` + : '🚫 Введи слова исключения через запятую (например: senior, lead, архитектор)\n\nПустое сообщение — убрать все исключения:' + const buttons: { text: string; callback_data: string }[] = [] + if (current) { + buttons.push({ text: '✅ Оставить текущие', callback_data: 'hh_keep_exclusions' }) + buttons.push({ text: '🗑 Очистить', callback_data: 'hh_clear_exclusions' }) + } + const msg = await bot.sendMessage(chatId, text, { + parse_mode: 'HTML', + reply_markup: { inline_keyboard: buttons.length ? [buttons] : [] }, + }) + state.exclusionPromptMessageId = msg.message_id +} + export async function handleAutoToggle(chatId: number): Promise { const state = getState(chatId) if (state.autoCron) { diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index c122cc7..dea6eff 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -20,8 +20,8 @@ interface VacancyCacheEntry { const vacancyCache = new Map() -function vacancyCacheKey(chatId: number, query: string, area: string | undefined, searchMode: string, resumeId: string | undefined): string { - return `${chatId}:${query}:${area ?? ''}:${searchMode}:${resumeId ?? ''}` +function vacancyCacheKey(chatId: number, query: string, area: string | undefined, searchMode: string, resumeId: string | undefined, excludedWords: string | undefined): string { + return `${chatId}:${query}:${area ?? ''}:${searchMode}:${resumeId ?? ''}:${excludedWords ?? ''}` } export function clearVacancyCache(chatId: number): void { @@ -275,7 +275,7 @@ export async function saveResume(chatId: number, resumeItem: ResumeListItem): Pr }) } -function buildVacancySearchUrl(query: string, searchMode: 'text' | 'resume', resumeId: string | undefined, area: string | undefined, page: number): string { +function buildVacancySearchUrl(query: string, searchMode: 'text' | 'resume', resumeId: string | undefined, area: string | undefined, page: number, excludedWords?: string): string { const parts: string[] = [] if (query && query !== '--') parts.push(`text=${encodeURIComponent(query)}`) @@ -283,6 +283,10 @@ function buildVacancySearchUrl(query: string, searchMode: 'text' | 'resume', res parts.push(`resume=${encodeURIComponent(resumeId)}`) if (area) parts.push(`area=${encodeURIComponent(area)}`) + if (excludedWords) { + for (const word of excludedWords.split(',').map(w => w.trim()).filter(Boolean)) + parts.push(`excluded_text=${encodeURIComponent(word)}`) + } parts.push('ored_clusters=true', 'items_on_page=100', `page=${page}`) return `https://hh.ru/search/vacancy?${parts.join('&')}` } @@ -319,7 +323,7 @@ async function collectPageVacancies(page: Page) { } export async function applyToJobs( - { query, area, maxApplies = 10, searchMode = 'text', resumeId }: ApplyOptions, + { query, area, maxApplies = 10, searchMode = 'text', resumeId, excludedWords }: ApplyOptions, { chatId, reporter }: { chatId: number, reporter: StatusReporter }, ): Promise { return withBrowser(async (browser) => { @@ -330,7 +334,7 @@ export async function applyToJobs( await loadSession(page, chatId) - const url = buildVacancySearchUrl(query, searchMode, resumeId, area, 0) + const url = buildVacancySearchUrl(query, searchMode, resumeId, area, 0, excludedWords) log.info(url) await page.goto(url, { waitUntil: 'domcontentloaded' }) @@ -342,7 +346,7 @@ export async function applyToJobs( await status('✅ Авторизация выполнена') - const cacheKey = vacancyCacheKey(chatId, query, area, searchMode, resumeId) + const cacheKey = vacancyCacheKey(chatId, query, area, searchMode, resumeId, excludedWords) const vacancies = vacancyCache.get(cacheKey)?.vacancies ?? null let allVacancies: Array<{ href: string, title: string }> @@ -368,7 +372,7 @@ export async function applyToJobs( log.info('URL:', page.url()) log.info('Max page:', maxPage) for (let p = 1; p <= maxPage; p++) { - const pageUrl = buildVacancySearchUrl(query, searchMode, resumeId, area, p) + const pageUrl = buildVacancySearchUrl(query, searchMode, resumeId, area, p, excludedWords) await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null) const more = await collectPageVacancies(page) diff --git a/src/hh/state.ts b/src/hh/state.ts index 252ac15..48694f6 100644 --- a/src/hh/state.ts +++ b/src/hh/state.ts @@ -9,12 +9,14 @@ export interface UserState { awaitingQuery: boolean awaitingMax: boolean awaitingPrompt: boolean + awaitingExclusions: boolean pendingResumes: ResumeListItem[] loginMethodMsgId: number | null loginPromptMessageId: number | null queryPromptMessageId: number | null maxPromptMessageId: number | null promptPromptMessageId: number | null + exclusionPromptMessageId: number | null onboardingStep: 'max' | 'query' | 'resume_mode' | 'prompt' | null onboardingMsgId: number | null onboardingAfterResume: boolean @@ -29,12 +31,14 @@ function makeUserState(): UserState { awaitingQuery: false, awaitingMax: false, awaitingPrompt: false, + awaitingExclusions: false, pendingResumes: [], loginMethodMsgId: null, loginPromptMessageId: null, queryPromptMessageId: null, maxPromptMessageId: null, promptPromptMessageId: null, + exclusionPromptMessageId: null, onboardingStep: null, onboardingMsgId: null, onboardingAfterResume: false, diff --git a/src/hh/types.ts b/src/hh/types.ts index a461592..1ee633f 100644 --- a/src/hh/types.ts +++ b/src/hh/types.ts @@ -4,6 +4,7 @@ export interface ApplyOptions { maxApplies?: number searchMode?: 'text' | 'resume' resumeId?: string + excludedWords?: string } export interface VacancyRef {