diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 9d79bc2..55ff8b8 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -1,6 +1,7 @@ import bot from '@bot' import prisma from '@prisma' import { debugFunc } from '@/hh/handlers/debug' +import { clearVacancyCache } from './scraper.js' import { handleApply } from './handlers/apply.js' import { doLogin, doLoginByPhone, handleLogin, handleLoginByEmail, handleLoginByPhone } from './handlers/auth.js' import { handleSkipped, handleStatus } from './handlers/info.js' @@ -91,6 +92,7 @@ const CALLBACK_HANDLERS: Record = { state.queryPromptMessageId = null await bot.deleteMessage(chatId, messageId).catch(() => {}) await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: '' } }) + clearVacancyCache(chatId) await bot.sendMessage(chatId, '🗑 Запрос очищен') }, hh_keep_max: async (chatId, messageId) => { @@ -274,6 +276,7 @@ export function registerHHCommands() { } await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } }) + clearVacancyCache(chatId) await bot.sendMessage(chatId, `✅ Запрос: «${updated.searchQuery}»`) await showResumeModeStep(chatId) return @@ -326,6 +329,7 @@ export function registerHHCommands() { state.queryPromptMessageId = null } const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } }) + clearVacancyCache(chatId) await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`) return } diff --git a/src/hh/handlers/region.ts b/src/hh/handlers/region.ts index 809ac2b..7542a03 100644 --- a/src/hh/handlers/region.ts +++ b/src/hh/handlers/region.ts @@ -1,5 +1,6 @@ import bot from '@bot' import prisma from '@prisma' +import { clearVacancyCache } from '../scraper.js' import { HH_AREAS, areaLabel } from '../areas.js' export async function handleRegion(chatId: number): Promise { @@ -21,6 +22,7 @@ export async function handleRegion(chatId: number): Promise { export async function handleAreaPick(chatId: number, messageId: number, rawCode: string): Promise { const code = rawCode === 'world' ? null : rawCode await prisma.settings.update({ where: { telegramId: chatId }, data: { area: code } }) + clearVacancyCache(chatId) await bot.deleteMessage(chatId, messageId).catch(() => {}) await bot.sendMessage(chatId, `✅ Регион: ${areaLabel(code)}`, { parse_mode: 'HTML' }) } diff --git a/src/hh/handlers/resume.ts b/src/hh/handlers/resume.ts index 52c55f5..e56bca4 100644 --- a/src/hh/handlers/resume.ts +++ b/src/hh/handlers/resume.ts @@ -1,7 +1,7 @@ import bot from '@bot' import prisma from '@prisma' import { createLogger } from '@/logger' -import { listResumes, NoResumeError, saveResume } from '../scraper.js' +import { clearVacancyCache, listResumes, NoResumeError, saveResume } from '../scraper.js' import { getState } from '../state.js' import { escapeHtml, NO_RESUME_MARKUP, safeEdit } from '../ui.js' import { startOnboarding } from './onboarding.js' @@ -94,6 +94,7 @@ export async function handleResumePick(chatId: number, messageId: number, idx: n reply_markup: { inline_keyboard: [] }, }) await saveResume(chatId, resume) + clearVacancyCache(chatId) state.pendingResumes = [] await safeEdit(`✅ Резюме выбрано: ${resume.title}`, { chat_id: chatId, diff --git a/src/hh/handlers/settings.ts b/src/hh/handlers/settings.ts index c8c05f7..a25aa38 100644 --- a/src/hh/handlers/settings.ts +++ b/src/hh/handlers/settings.ts @@ -1,6 +1,7 @@ import bot from '@bot' import prisma from '@prisma' import cron from 'node-cron' +import { clearVacancyCache } from '../scraper.js' import { buildSettingsKeyboard, escapeHtml } from '../ui.js' import { getState } from '../state.js' @@ -9,6 +10,7 @@ export async function handleSearchModeToggle(chatId: number): Promise { const current = settings?.searchMode ?? 'text' const next = current === 'text' ? 'resume' : 'text' await prisma.settings.update({ where: { telegramId: chatId }, data: { searchMode: next } }) + clearVacancyCache(chatId) const text = next === 'resume' ? `✅ Поиск по резюме включён\n\n• Есть ключевые слова → ?text=...&resume=...\n• Ключевые слова пусты → только ?resume=...` diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index ce96956..c122cc7 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -11,6 +11,28 @@ import { createStatusReporter, escapeHtml } from './ui.js' const log = createLogger('scraper') +const VACANCY_CACHE_TTL = 60 * 60 * 1000 + +interface VacancyCacheEntry { + vacancies: Array<{ href: string, title: string }> + timer: ReturnType +} + +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 ?? ''}` +} + +export function clearVacancyCache(chatId: number): void { + for (const [key, entry] of vacancyCache.entries()) { + if (key.startsWith(`${chatId}:`)) { + clearTimeout(entry.timer) + vacancyCache.delete(key) + } + } +} + export class NoResumeError extends Error { constructor() { super('no_resume') @@ -320,33 +342,47 @@ export async function applyToJobs( await status('✅ Авторизация выполнена') - const vacancies = await collectPageVacancies(page) + const cacheKey = vacancyCacheKey(chatId, query, area, searchMode, resumeId) + const vacancies = vacancyCache.get(cacheKey)?.vacancies ?? null - const pagerBlock = await page.$('[data-qa="pager-block"]') - if (pagerBlock) { - const maxPage = await page.$$eval( - '[data-qa="pager-block"] [data-qa="pager-page"]', - links => Math.max(...links.map((a) => { - const pageParam = new URL((a as HTMLAnchorElement).href).searchParams.get('page') - return Number(pageParam ?? 0) - })), - ) - log.divider('CollectPageVacancies') - 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) - await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }) - await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null) - const more = await collectPageVacancies(page) - log.divider('Page Vacancies') - log.info(more) - vacancies.push(...more) - await status(`🔎 Страница ${p}`) - } + let allVacancies: Array<{ href: string, title: string }> + + if (vacancies) { + log.info('Vacancies from cache:', vacancies.length) + allVacancies = vacancies + await keep(`✅ Вакансий из кэша: ${allVacancies.length}`) } + else { + allVacancies = await collectPageVacancies(page) - await keep(`✅ Вакансий найдено: ${vacancies.length}`) + const pagerBlock = await page.$('[data-qa="pager-block"]') + if (pagerBlock) { + const maxPage = await page.$$eval( + '[data-qa="pager-block"] [data-qa="pager-page"]', + links => Math.max(...links.map((a) => { + const pageParam = new URL((a as HTMLAnchorElement).href).searchParams.get('page') + return Number(pageParam ?? 0) + })), + ) + log.divider('CollectPageVacancies') + 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) + await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null) + const more = await collectPageVacancies(page) + log.divider('Page Vacancies') + log.info(more) + allVacancies.push(...more) + await status(`🔎 Страница ${p}`) + } + } + + const timer = setTimeout(() => vacancyCache.delete(cacheKey), VACANCY_CACHE_TTL).unref() + vacancyCache.set(cacheKey, { vacancies: allVacancies, timer }) + await keep(`✅ Вакансий найдено: ${allVacancies.length}`) + } const resumes = await prisma.resume.findMany({ where: { telegramId: chatId } }) const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) @@ -366,7 +402,7 @@ export async function applyToJobs( let appliedCount = 0 let consecutiveSkips = 0 const MAX_CONSECUTIVE_SKIPS = 20 - for (const vacancy of vacancies) { + for (const vacancy of allVacancies) { if (appliedCount >= maxApplies) break if (consecutiveSkips >= MAX_CONSECUTIVE_SKIPS) {