🆕 feat(src/hh/scraper): добавляет кэширование вакансий для улучшения производительности поиска
Some checks are pending
Deploy / deploy (push) Has started running

♻️ chore(src/hh/handlers/region): очищает кэш вакансий при изменении региона

♻️ chore(src/hh/bot-commands): очищает кэш вакансий при сбросе запроса

♻️ chore(src/hh/handlers/settings): очищает кэш вакансий при изменении режима поиска

♻️ chore(src/hh/handlers/resume): очищает кэш вакансий при сохранении резюме
This commit is contained in:
Oscar
2026-06-11 15:49:50 +03:00
parent 010910d684
commit e752a563e2
8 changed files with 75 additions and 10 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "excludedWords" TEXT;

View File

@@ -45,6 +45,7 @@ model Settings {
selectedResumeId String? selectedResumeId String?
searchMode String @default("text") searchMode String @default("text")
area String? area String?
excludedWords String?
} }
model SkippedVacancy { model SkippedVacancy {

View File

@@ -8,7 +8,7 @@ import { handleSkipped, handleStatus } from './handlers/info.js'
import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo, showResumeModeStep } from './handlers/onboarding.js' import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo, showResumeModeStep } from './handlers/onboarding.js'
import { handleAreaPick, handleRegion } from './handlers/region.js' import { handleAreaPick, handleRegion } from './handlers/region.js'
import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.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 { getState } from './state.js'
import { BTN, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, buildFiltersKeyboard, buildSettingsKeyboard } from './ui.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<Record<string, MsgHandler>> = {
[BTN.INFO]: async (chatId) => { await bot.sendMessage(chatId, ' Информация:', { reply_markup: INFO_REPLY_KEYBOARD }) }, [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.BACK]: async (chatId) => { await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) },
[BTN.REGION]: handleRegion, [BTN.REGION]: handleRegion,
[BTN.EXCLUSIONS]: handleExclusions,
} }
const CALLBACK_HANDLERS: Record<string, CallbackHandler> = { const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
@@ -101,6 +102,21 @@ const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
state.maxPromptMessageId = null state.maxPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {}) 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) => { hh_keep_prompt: async (chatId, messageId) => {
const state = getState(chatId) const state = getState(chatId)
state.awaitingPrompt = false state.awaitingPrompt = false
@@ -178,6 +194,7 @@ async function clearAwaitingState(chatId: number): Promise<void> {
state.queryPromptMessageId, state.queryPromptMessageId,
state.maxPromptMessageId, state.maxPromptMessageId,
state.promptPromptMessageId, state.promptPromptMessageId,
state.exclusionPromptMessageId,
state.onboardingMsgId, state.onboardingMsgId,
] ]
state.awaitingEmail = false state.awaitingEmail = false
@@ -185,6 +202,7 @@ async function clearAwaitingState(chatId: number): Promise<void> {
state.awaitingQuery = false state.awaitingQuery = false
state.awaitingMax = false state.awaitingMax = false
state.awaitingPrompt = false state.awaitingPrompt = false
state.awaitingExclusions = false
state.onboardingStep = null state.onboardingStep = null
state.onboardingMsgId = null state.onboardingMsgId = null
state.loginMethodMsgId = null state.loginMethodMsgId = null
@@ -192,6 +210,7 @@ async function clearAwaitingState(chatId: number): Promise<void> {
state.queryPromptMessageId = null state.queryPromptMessageId = null
state.maxPromptMessageId = null state.maxPromptMessageId = null
state.promptPromptMessageId = null state.promptPromptMessageId = null
state.exclusionPromptMessageId = null
await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {}))) await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {})))
} }
@@ -241,7 +260,7 @@ export function registerHHCommands() {
return 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]) 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.SEARCH_MODE_TOGGLE)
|| msg.text.startsWith(BTN.REGION) || msg.text.startsWith(BTN.REGION)
@@ -351,6 +370,20 @@ export function registerHHCommands() {
return 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 ? `🚫 Слова исключения: <b>${normalized}</b>` : '🗑 Слова исключения очищены', { parse_mode: 'HTML' })
return
}
if (state.awaitingPrompt) { if (state.awaitingPrompt) {
state.awaitingPrompt = false state.awaitingPrompt = false
await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) await bot.deleteMessage(chatId, msg.message_id).catch(() => {})

View File

@@ -21,7 +21,7 @@ export async function handleApply(chatId: number): Promise<void> {
await bot.sendMessage(chatId, `🔄 Ищу вакансии ${searchLabel}...`, { reply_markup: APPLYING_REPLY_KEYBOARD }) await bot.sendMessage(chatId, `🔄 Ищу вакансии ${searchLabel}...`, { reply_markup: APPLYING_REPLY_KEYBOARD })
applyToJobs( 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 }, { chatId, reporter },
) )
.then(async (result) => { .then(async (result) => {

View File

@@ -82,6 +82,26 @@ export async function handlePrompt(chatId: number): Promise<void> {
state.promptPromptMessageId = msg.message_id state.promptPromptMessageId = msg.message_id
} }
export async function handleExclusions(chatId: number): Promise<void> {
const state = getState(chatId)
state.awaitingExclusions = true
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const current = settings?.excludedWords ?? ''
const text = current
? `🚫 Текущие слова исключения: <b>${escapeHtml(current)}</b>\n\nВведи новые через запятую или очисти:`
: '🚫 Введи слова исключения через запятую (например: <code>senior, lead, архитектор</code>)\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<void> { export async function handleAutoToggle(chatId: number): Promise<void> {
const state = getState(chatId) const state = getState(chatId)
if (state.autoCron) { if (state.autoCron) {

View File

@@ -20,8 +20,8 @@ interface VacancyCacheEntry {
const vacancyCache = new Map<string, VacancyCacheEntry>() const vacancyCache = new Map<string, VacancyCacheEntry>()
function vacancyCacheKey(chatId: number, query: string, area: string | undefined, searchMode: string, resumeId: string | undefined): string { function vacancyCacheKey(chatId: number, query: string, area: string | undefined, searchMode: string, resumeId: string | undefined, excludedWords: string | undefined): string {
return `${chatId}:${query}:${area ?? ''}:${searchMode}:${resumeId ?? ''}` return `${chatId}:${query}:${area ?? ''}:${searchMode}:${resumeId ?? ''}:${excludedWords ?? ''}`
} }
export function clearVacancyCache(chatId: number): void { 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[] = [] const parts: string[] = []
if (query && query !== '--') if (query && query !== '--')
parts.push(`text=${encodeURIComponent(query)}`) parts.push(`text=${encodeURIComponent(query)}`)
@@ -283,6 +283,10 @@ function buildVacancySearchUrl(query: string, searchMode: 'text' | 'resume', res
parts.push(`resume=${encodeURIComponent(resumeId)}`) parts.push(`resume=${encodeURIComponent(resumeId)}`)
if (area) if (area)
parts.push(`area=${encodeURIComponent(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}`) parts.push('ored_clusters=true', 'items_on_page=100', `page=${page}`)
return `https://hh.ru/search/vacancy?${parts.join('&')}` return `https://hh.ru/search/vacancy?${parts.join('&')}`
} }
@@ -319,7 +323,7 @@ async function collectPageVacancies(page: Page) {
} }
export async function applyToJobs( 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 }, { chatId, reporter }: { chatId: number, reporter: StatusReporter },
): Promise<ApplyResult> { ): Promise<ApplyResult> {
return withBrowser(async (browser) => { return withBrowser(async (browser) => {
@@ -330,7 +334,7 @@ export async function applyToJobs(
await loadSession(page, chatId) await loadSession(page, chatId)
const url = buildVacancySearchUrl(query, searchMode, resumeId, area, 0) const url = buildVacancySearchUrl(query, searchMode, resumeId, area, 0, excludedWords)
log.info(url) log.info(url)
await page.goto(url, { waitUntil: 'domcontentloaded' }) await page.goto(url, { waitUntil: 'domcontentloaded' })
@@ -342,7 +346,7 @@ export async function applyToJobs(
await status('✅ Авторизация выполнена') 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 const vacancies = vacancyCache.get(cacheKey)?.vacancies ?? null
let allVacancies: Array<{ href: string, title: string }> let allVacancies: Array<{ href: string, title: string }>
@@ -368,7 +372,7 @@ export async function applyToJobs(
log.info('URL:', page.url()) log.info('URL:', page.url())
log.info('Max page:', maxPage) log.info('Max page:', maxPage)
for (let p = 1; p <= maxPage; p++) { 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.goto(pageUrl, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null) await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null)
const more = await collectPageVacancies(page) const more = await collectPageVacancies(page)

View File

@@ -9,12 +9,14 @@ export interface UserState {
awaitingQuery: boolean awaitingQuery: boolean
awaitingMax: boolean awaitingMax: boolean
awaitingPrompt: boolean awaitingPrompt: boolean
awaitingExclusions: boolean
pendingResumes: ResumeListItem[] pendingResumes: ResumeListItem[]
loginMethodMsgId: number | null loginMethodMsgId: number | null
loginPromptMessageId: number | null loginPromptMessageId: number | null
queryPromptMessageId: number | null queryPromptMessageId: number | null
maxPromptMessageId: number | null maxPromptMessageId: number | null
promptPromptMessageId: number | null promptPromptMessageId: number | null
exclusionPromptMessageId: number | null
onboardingStep: 'max' | 'query' | 'resume_mode' | 'prompt' | null onboardingStep: 'max' | 'query' | 'resume_mode' | 'prompt' | null
onboardingMsgId: number | null onboardingMsgId: number | null
onboardingAfterResume: boolean onboardingAfterResume: boolean
@@ -29,12 +31,14 @@ function makeUserState(): UserState {
awaitingQuery: false, awaitingQuery: false,
awaitingMax: false, awaitingMax: false,
awaitingPrompt: false, awaitingPrompt: false,
awaitingExclusions: false,
pendingResumes: [], pendingResumes: [],
loginMethodMsgId: null, loginMethodMsgId: null,
loginPromptMessageId: null, loginPromptMessageId: null,
queryPromptMessageId: null, queryPromptMessageId: null,
maxPromptMessageId: null, maxPromptMessageId: null,
promptPromptMessageId: null, promptPromptMessageId: null,
exclusionPromptMessageId: null,
onboardingStep: null, onboardingStep: null,
onboardingMsgId: null, onboardingMsgId: null,
onboardingAfterResume: false, onboardingAfterResume: false,

View File

@@ -4,6 +4,7 @@ export interface ApplyOptions {
maxApplies?: number maxApplies?: number
searchMode?: 'text' | 'resume' searchMode?: 'text' | 'resume'
resumeId?: string resumeId?: string
excludedWords?: string
} }
export interface VacancyRef { export interface VacancyRef {