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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Settings" ADD COLUMN "excludedWords" TEXT;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(() => {})
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user