mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-06-08 18:04:57 +00:00
init
This commit is contained in:
212
src/hh/bot-commands.ts
Normal file
212
src/hh/bot-commands.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import bot from '@bot'
|
||||
import prisma from '@prisma'
|
||||
import cron, { type ScheduledTask } from 'node-cron'
|
||||
import { applyToJobs, checkIsAuth, login } from './scraper.js'
|
||||
|
||||
interface State {
|
||||
autoCron: ScheduledTask | null
|
||||
awaitingEmail: boolean
|
||||
awaitingPassword: boolean
|
||||
awaitingQuery: boolean
|
||||
awaitingMax: boolean
|
||||
awaitingOTP: boolean
|
||||
tempEmail: string
|
||||
}
|
||||
|
||||
export function triggerHHStart(chatId: number): void {
|
||||
bot.sendMessage(chatId, '🤖 HH Auto-Apply', {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '🚀 Откликнуться сейчас', callback_data: 'hh_apply' },
|
||||
],
|
||||
[
|
||||
{ text: '🔍 Изменить запрос', callback_data: 'hh_query' },
|
||||
{ text: '🔢 Макс откликов', callback_data: 'hh_max' },
|
||||
],
|
||||
[
|
||||
{ text: '⏰ Авто вкл', callback_data: 'hh_auto_start' },
|
||||
{ text: '⛔ Авто выкл', callback_data: 'hh_auto_stop' },
|
||||
],
|
||||
[
|
||||
{ text: '🔑 Логин', callback_data: 'hh_login' },
|
||||
{ text: '⚙️ Статус', callback_data: 'hh_status' },
|
||||
],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function registerHHCommands() {
|
||||
const state: State = {
|
||||
autoCron: null,
|
||||
awaitingEmail: false,
|
||||
awaitingPassword: false,
|
||||
awaitingQuery: false,
|
||||
awaitingMax: false,
|
||||
awaitingOTP: false,
|
||||
tempEmail: '',
|
||||
}
|
||||
|
||||
bot.onText(/\/hhstart/, (msg) => {
|
||||
triggerHHStart(msg.chat.id)
|
||||
})
|
||||
|
||||
// Инлайн кнопки
|
||||
bot.on('callback_query', async (query) => {
|
||||
if (!query.message)
|
||||
return
|
||||
const chatId = query.message.chat.id
|
||||
|
||||
bot.answerCallbackQuery(query.id)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { telegramId: chatId },
|
||||
include: { Settings: true },
|
||||
})
|
||||
|
||||
const settings = user!.Settings!
|
||||
|
||||
switch (query.data) {
|
||||
case 'hh_apply':
|
||||
await bot.sendMessage(chatId, `🚀 Ищу: "${settings.searchQuery}"...`)
|
||||
applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, {
|
||||
chatId,
|
||||
}).then((result) => {
|
||||
if (result.error)
|
||||
return bot.sendMessage(chatId, `❌ ${result.error}`)
|
||||
const lines = result.applied.map((v, i) => `${i + 1}. ${v}`).join('\n')
|
||||
bot.sendMessage(chatId, `✅ Откликнулся: ${result.applied.length}\n${lines}\n\n`
|
||||
+ `⏭ Пропущено: ${result.skipped.length}\n${
|
||||
result.errors.length ? `❌ Ошибок: ${result.errors.length}` : ''}`)
|
||||
})
|
||||
break
|
||||
|
||||
case 'hh_status':
|
||||
await bot.sendMessage(chatId, `⚙️ Настройки:\n
|
||||
Запрос: ${settings.searchQuery}\n
|
||||
Макс откликов: ${settings.maxApplies}\n
|
||||
Авто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\n
|
||||
Авторизован: ${await checkIsAuth(chatId)}`)
|
||||
break
|
||||
|
||||
case 'hh_login':
|
||||
state.awaitingEmail = true
|
||||
|
||||
if (!user?.hhEmail) {
|
||||
await bot.sendMessage(chatId, '📧 Введи email от hh.ru:')
|
||||
}
|
||||
else {
|
||||
await bot.sendMessage(chatId, `📧 Email от hh.ru: ${user?.hhEmail}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'hh_query':
|
||||
state.awaitingQuery = true
|
||||
await bot.sendMessage(chatId, '🔍 Введи поисковый запрос:')
|
||||
break
|
||||
|
||||
case 'hh_max':
|
||||
state.awaitingMax = true
|
||||
await bot.sendMessage(chatId, '🔢 Введи максимальное количество откликов (1-50):')
|
||||
break
|
||||
|
||||
case 'hh_auto_start':
|
||||
if (state.autoCron) {
|
||||
await bot.sendMessage(chatId, 'Уже запущено!')
|
||||
break
|
||||
}
|
||||
state.autoCron = cron.schedule('0 10 * * 1-5', async () => {
|
||||
await bot.sendMessage(chatId, '⏰ Авто-отклик...')
|
||||
// const result = await applyToJobs({ query: state.searchQuery, maxApplies: state.maxApplies }, { bot, chatId, msg })
|
||||
// await bot.sendMessage(chatId, `✅ Откликнулся на ${result.applied.length} вакансий`)
|
||||
})
|
||||
await bot.sendMessage(chatId, '✅ Авто включён (пн-пт, 10:00)')
|
||||
break
|
||||
|
||||
case 'hh_auto_stop':
|
||||
state.autoCron?.stop()
|
||||
state.autoCron = null
|
||||
await bot.sendMessage(chatId, '⛔ Авто остановлен')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
bot.on('message', async (msg) => {
|
||||
const chatId = msg.chat.id
|
||||
|
||||
if (!msg.text || msg.text.startsWith('/'))
|
||||
return
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { telegramId: chatId },
|
||||
include: { Settings: true },
|
||||
})
|
||||
|
||||
if (state.awaitingEmail) {
|
||||
if (!user?.hhEmail) {
|
||||
state.tempEmail = msg.text
|
||||
}
|
||||
else {
|
||||
state.tempEmail = user?.hhEmail
|
||||
}
|
||||
|
||||
state.awaitingEmail = false
|
||||
|
||||
await bot.deleteMessage(chatId, msg.message_id).catch(() => {
|
||||
})
|
||||
|
||||
await bot.sendMessage(chatId, '🔄 Логинюсь...')
|
||||
try {
|
||||
await bot.sendMessage(chatId, `${state.tempEmail}, ${msg.text}`)
|
||||
|
||||
await login(state.tempEmail, chatId)
|
||||
await bot.sendMessage(chatId, '✅ Авторизован! Куки сохранены.')
|
||||
|
||||
await prisma.user.update({
|
||||
where: { telegramId: chatId },
|
||||
data: {
|
||||
hhEmail: state.tempEmail,
|
||||
},
|
||||
})
|
||||
|
||||
triggerHHStart(chatId)
|
||||
}
|
||||
catch (e) {
|
||||
await bot.sendMessage(chatId, `😬 Ошибка: ${(e as Error).message}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.awaitingQuery) {
|
||||
state.awaitingQuery = false
|
||||
|
||||
const updated = await prisma.settings.update({
|
||||
where: { telegramId: chatId },
|
||||
data: {
|
||||
searchQuery: msg.text,
|
||||
},
|
||||
})
|
||||
|
||||
await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.awaitingMax) {
|
||||
state.awaitingMax = false
|
||||
const num = Number(msg.text)
|
||||
if (num < 1 || num > 50) {
|
||||
await bot.sendMessage(chatId, '❌ Число от 1 до 50')
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.settings.update({
|
||||
where: { telegramId: chatId },
|
||||
data: {
|
||||
maxApplies: num,
|
||||
},
|
||||
})
|
||||
await bot.sendMessage(chatId, `✅ Макс откликов: ${updated.maxApplies}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
307
src/hh/scraper.ts
Normal file
307
src/hh/scraper.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import type { Message } from 'node-telegram-bot-api'
|
||||
import bot from '@bot'
|
||||
import prisma from '@prisma'
|
||||
import { type Browser, chromium, type Page } from 'playwright'
|
||||
import { askGPT } from '../openai'
|
||||
|
||||
// const SESSION_FILE = path.resolve('./session.json')
|
||||
|
||||
interface ApplyOptions {
|
||||
query: string
|
||||
area?: number
|
||||
maxApplies?: number
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
applied: string[]
|
||||
skipped: string[]
|
||||
errors: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
function randomDelay(min = 300, max = 2000) {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
async function humanDelay(min = 300, max = 2000) {
|
||||
return new Promise(r => setTimeout(r, randomDelay(min, max)))
|
||||
}
|
||||
|
||||
async function randomScroll(page: Page) {
|
||||
await page.mouse.move(100 + Math.random() * 500, 200 + Math.random() * 500)
|
||||
await page.mouse.wheel(0, 300 + Math.random() * 1000)
|
||||
}
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
return chromium.launch({ headless: true })
|
||||
}
|
||||
|
||||
async function loadSession(page: Page, telegramId: bigint | number): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { telegramId },
|
||||
})
|
||||
|
||||
const session = user?.session
|
||||
|
||||
if (!session)
|
||||
return false
|
||||
|
||||
const cookies = JSON.parse(session)
|
||||
await page.context().addCookies(cookies)
|
||||
return true
|
||||
}
|
||||
|
||||
function waitForOtp(chatId: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (msg: Message) => {
|
||||
if (msg.chat.id !== chatId)
|
||||
return
|
||||
|
||||
if (!msg.text)
|
||||
return
|
||||
|
||||
bot.removeListener('message', handler)
|
||||
resolve(msg.text)
|
||||
}
|
||||
|
||||
bot.on('message', handler)
|
||||
})
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
chatId: number,
|
||||
): Promise<void> {
|
||||
const browser = await getBrowser()
|
||||
await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`)
|
||||
if (!browser.version())
|
||||
return
|
||||
await bot.sendMessage(chatId, `Browser version: ${browser.version()}`)
|
||||
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.goto('https://hh.ru/account/login', { waitUntil: 'networkidle' })
|
||||
|
||||
await bot.sendMessage(chatId, `page: ${page.url()}`)
|
||||
|
||||
await page.click('[data-qa="submit-button"]')
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await bot.sendMessage(chatId, `Клик по "Войти"`)
|
||||
|
||||
await page.click('label:has([data-qa="credential-type-EMAIL"])')
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await bot.sendMessage(chatId, `Клик по "Email"`)
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await page.fill('[data-qa="applicant-login-input-email"]', email)
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await bot.sendMessage(chatId, `Ввод "Email"`)
|
||||
|
||||
await page.click('[data-qa="submit-button"]')
|
||||
|
||||
await bot.sendMessage(chatId, `Клик по "Дальше"`)
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await bot.sendMessage(chatId, '🔑 Введи код из email')
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await page.click('[data-qa="applicant-login-input-otp"]')
|
||||
|
||||
const otp = await waitForOtp(chatId)
|
||||
|
||||
await page.fill('[data-qa="applicant-login-input-otp"] input', otp)
|
||||
|
||||
await bot.sendMessage(chatId, `Введён ОТП: ${otp}`)
|
||||
|
||||
await page.waitForTimeout(randomDelay())
|
||||
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const cookies = await context.cookies()
|
||||
|
||||
await prisma.user.update({
|
||||
where: { telegramId: chatId },
|
||||
data: {
|
||||
session: JSON.stringify(cookies, null, 2),
|
||||
},
|
||||
})
|
||||
|
||||
await bot.sendMessage(chatId, `cookies: ${cookies.length}`)
|
||||
|
||||
await browser.close()
|
||||
|
||||
await getResume(chatId)
|
||||
}
|
||||
|
||||
export async function checkIsAuth(telegramId: bigint | number) {
|
||||
const browser = await getBrowser()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
await loadSession(page, telegramId)
|
||||
|
||||
console.log('Сессия загружена')
|
||||
|
||||
const url = `https://hh.ru/search/vacancy`
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
|
||||
try {
|
||||
return await page.$('[data-qa="profileAndResumes-button"]')
|
||||
}
|
||||
catch (e) {
|
||||
return e
|
||||
}
|
||||
// return await page.$('[data-qa="mainmenu_createResume"]')
|
||||
}
|
||||
|
||||
export async function getResume(chatId: number) {
|
||||
const browser = await getBrowser()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await loadSession(page, chatId)
|
||||
const url = `https://hh.ru/applicant/resumes`
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
|
||||
const text = await page
|
||||
.$('[data-qa^="resume-card-link-"]')
|
||||
.then(html => html?.getAttribute('href'))
|
||||
|
||||
const hhUrl = new URL(`https://hh.ru${text}`)
|
||||
|
||||
const id = hhUrl.pathname.split('/').pop()!
|
||||
|
||||
const resumeUrl = `https://hh.ru/resume_converter/resume.txt?hash=${id}&type=txt&hhtmFrom=&hhtmSource=resume`
|
||||
|
||||
await page.goto(resumeUrl, { waitUntil: 'networkidle' })
|
||||
|
||||
let resume
|
||||
try {
|
||||
resume = await page.locator('.resume').innerText()
|
||||
|
||||
await prisma.resume.upsert({
|
||||
where: { id },
|
||||
create: { data: resume, id, telegramId: chatId },
|
||||
update: { data: resume },
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
await bot.sendMessage(chatId, 'Нет резюме на ХХ, создайте')
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
return resume
|
||||
}
|
||||
|
||||
export async function applyToJobs({
|
||||
query,
|
||||
area = 1,
|
||||
maxApplies = 10,
|
||||
}: ApplyOptions, { chatId }: {
|
||||
chatId: number
|
||||
}): Promise<ApplyResult> {
|
||||
const browser = await getBrowser()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
const results: ApplyResult = { applied: [], skipped: [], errors: [] }
|
||||
|
||||
try {
|
||||
await loadSession(page, chatId)
|
||||
|
||||
const url = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&area=${area}`
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
|
||||
const isLoggedIn = await page.$('[data-qa="profileAndResumes-button"]')
|
||||
// await page.$('[data-qa="mainmenu_myResumes"]')
|
||||
if (!isLoggedIn) {
|
||||
return { ...results, error: 'Не авторизован. Выполните login' }
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, `✅ Авторизация выполнена`)
|
||||
|
||||
const vacancies = await page.$$eval(
|
||||
'[data-qa="serp-item__title"]',
|
||||
links => links.map(a => ({
|
||||
href: (a as HTMLAnchorElement).href,
|
||||
title: a.textContent?.trim() ?? '',
|
||||
})),
|
||||
)
|
||||
|
||||
await bot.sendMessage(chatId, `✅ Вакансий найдено: ${vacancies.length}`)
|
||||
|
||||
for (const vacancy of vacancies.slice(0, maxApplies)) {
|
||||
try {
|
||||
await bot.sendMessage(chatId, `🔄 Обрабатывается вакансия: ${vacancy.title}`)
|
||||
await page.goto(vacancy.href, { waitUntil: 'networkidle' })
|
||||
|
||||
const description = await page
|
||||
.locator('[data-qa="vacancy-description"]')
|
||||
.innerText()
|
||||
|
||||
if (!description) {
|
||||
await bot.sendMessage(chatId, `😬 Ошибка с получением описания`)
|
||||
continue
|
||||
}
|
||||
|
||||
await bot.sendMessage(chatId, `✅ Описание получено`)
|
||||
|
||||
const resume = await prisma.resume.findFirst({
|
||||
where: { telegramId: chatId },
|
||||
})
|
||||
|
||||
if (!resume?.data) {
|
||||
await getResume(chatId)
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { telegramId: chatId },
|
||||
})
|
||||
|
||||
const letter = await askGPT(resume!.data, description, user!.prompt)
|
||||
|
||||
await bot.sendMessage(chatId, `✅ Сопроводительное письмо отправлено: ${letter}`)
|
||||
|
||||
// const applyBtn = await page.$('[data-qa="vacancy-response-link-top"]')
|
||||
// if (!applyBtn) {
|
||||
// results.skipped.push(vacancy.title)
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// await randomScroll(page)
|
||||
//
|
||||
// await applyBtn.click()
|
||||
// await page.waitForTimeout(randomDelay())
|
||||
//
|
||||
// const submitBtn = await page.$('[data-qa="vacancy-response-popup-submit"]')
|
||||
// if (submitBtn) {
|
||||
// await submitBtn.click()
|
||||
// await page.waitForTimeout(randomDelay())
|
||||
// }
|
||||
|
||||
results.applied.push(vacancy.title)
|
||||
// await page.waitForTimeout(3000 + Math.random() * 2000)
|
||||
}
|
||||
catch (err) {
|
||||
results.errors.push(`${vacancy.title}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
Reference in New Issue
Block a user