mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-07-24 21:04:58 +00:00
🆕 feat(src/hh/scraper): добавляет кэширование вакансий для улучшения производительности поиска
Some checks are pending
Deploy / deploy (push) Has started running
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:
@@ -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<Record<string, MsgHandler>> = {
|
||||
[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<string, CallbackHandler> = {
|
||||
@@ -101,6 +102,21 @@ const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
|
||||
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<void> {
|
||||
state.queryPromptMessageId,
|
||||
state.maxPromptMessageId,
|
||||
state.promptPromptMessageId,
|
||||
state.exclusionPromptMessageId,
|
||||
state.onboardingMsgId,
|
||||
]
|
||||
state.awaitingEmail = false
|
||||
@@ -185,6 +202,7 @@ async function clearAwaitingState(chatId: number): Promise<void> {
|
||||
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<void> {
|
||||
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 ? `🚫 Слова исключения: <b>${normalized}</b>` : '🗑 Слова исключения очищены', { parse_mode: 'HTML' })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.awaitingPrompt) {
|
||||
state.awaitingPrompt = false
|
||||
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function handleApply(chatId: number): Promise<void> {
|
||||
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) => {
|
||||
|
||||
@@ -82,6 +82,26 @@ export async function handlePrompt(chatId: number): Promise<void> {
|
||||
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> {
|
||||
const state = getState(chatId)
|
||||
if (state.autoCron) {
|
||||
|
||||
@@ -20,8 +20,8 @@ interface VacancyCacheEntry {
|
||||
|
||||
const vacancyCache = new Map<string, VacancyCacheEntry>()
|
||||
|
||||
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<ApplyResult> {
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ApplyOptions {
|
||||
maxApplies?: number
|
||||
searchMode?: 'text' | 'resume'
|
||||
resumeId?: string
|
||||
excludedWords?: string
|
||||
}
|
||||
|
||||
export interface VacancyRef {
|
||||
|
||||
Reference in New Issue
Block a user