diff --git a/package.json b/package.json index edaa83f..05b5293 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "packageManager": "yarn@4.6.0", "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch --env-file=.env src/index.ts", "build": "tsc", - "start": "node dist/index.js", + "start": "node --env-file=.env dist/index.js", "db-view": "yarn prisma studio", "db-migrate": "npx prisma migrate dev --name init npx prisma generate ", "db-deploy": "npx prisma migrate deploy", diff --git a/src/bot-singleton.ts b/src/bot-singleton.ts index 638da71..5c6ad91 100644 --- a/src/bot-singleton.ts +++ b/src/bot-singleton.ts @@ -1,6 +1,8 @@ +import process from 'node:process' + import TelegramBot from 'node-telegram-bot-api' -const token = '8150213101:AAFfkqu32aWImOfIaarnQqtWaUj8ZoAwHLE' +const token = process.env.TG_BOT_TOKEN! const bot = new TelegramBot(token, { polling: true }) export default bot diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 132fe85..06c1a04 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -1,11 +1,9 @@ import bot from '@bot' import prisma from '@prisma' import cron, { type ScheduledTask } from 'node-cron' -import { applyToJobs, checkIsAuth, listResumes, login, type ResumeListItem, saveResume } from './scraper.js' - -function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>') -} +import { applyToJobs, checkIsAuth, listResumes, login, saveResume } from './scraper.js' +import { type ResumeListItem } from './types.js' +import { BACK_MARKUP, createStatusReporter, escapeHtml, LOGIN_MARKUP, MAIN_MARKUP, showResult } from './ui.js' interface UserState { autoCron: ScheduledTask | null @@ -39,33 +37,6 @@ function getState(chatId: number): UserState { return states.get(chatId)! } -const MAIN_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' }, - ], - [ - { text: '📄 Выбрать резюме', callback_data: 'hh_resume_list' }, - { text: '📋 Моё резюме', callback_data: 'hh_my_resume' }, - ], - ], -} - -const BACK_MARKUP = { - inline_keyboard: [[{ text: '◀️ Назад', callback_data: 'hh_back' }]], -} - -// Редактирует существующее сообщение-меню или отправляет новое async function showMenu(chatId: number, messageId?: number | null): Promise { const state = getState(chatId) const targetId = messageId ?? state.menuMessageId @@ -85,21 +56,10 @@ async function showMenu(chatId: number, messageId?: number | null): Promise { - await bot.editMessageText(text, { - chat_id: chatId, - message_id: messageId, - reply_markup: BACK_MARKUP, - }) -} - async function sendResumeSelector(chatId: number, resumes: ResumeListItem[], messageId: number): Promise { const state = getState(chatId) state.pendingResumes = resumes @@ -146,6 +106,13 @@ async function doLogin(chatId: number, email: string): Promise { } export async function triggerHHStart(chatId: number): Promise { + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + if (!user?.session) { + const state = getState(chatId) + const msg = await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: LOGIN_MARKUP }) + state.menuMessageId = msg.message_id + return + } await showMenu(chatId) } @@ -176,21 +143,19 @@ export function registerHHCommands() { break case 'hh_apply': { - await bot.editMessageText(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`, { - chat_id: chatId, - message_id: messageId, - reply_markup: { inline_keyboard: [] }, - }) + await bot.deleteMessage(chatId, messageId).catch(() => {}) state.menuMessageId = null - applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId }) + const reporter = createStatusReporter(chatId) + await reporter.status(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) + + applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter }) .then(async (result) => { if (result.error) { await bot.sendMessage(chatId, `❌ ${result.error}`) } else { const lines: string[] = [] - lines.push(`📊 Итого по запросу «${settings.searchQuery}»`) lines.push(`✅ Откликнулся: ${result.applied.length}`) lines.push(`⏭ Пропущено: ${result.skipped.length}`) @@ -224,7 +189,7 @@ export function registerHHCommands() { await showResult( chatId, messageId, - `⚙️ Настройки:\n\nЗапрос: ${settings.searchQuery}\nМакс откликов: ${settings.maxApplies}\nАвто: ${state.autoCron ? '✅ включено' : '😬 выключено'}\nАвторизован: ${isAuth}`, + `⚙️ Настройки:\n\nЗапрос: ${settings.searchQuery}\nМакс откликов: ${settings.maxApplies}\nАвто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\nАвторизован: ${isAuth}`, ) break } @@ -238,12 +203,10 @@ export function registerHHCommands() { await showResult(chatId, messageId, '📋 Резюме не найдено.\n\nВыбери резюме через кнопку 📄 Выбрать резюме.') break } - const MAX = 3800 const text = resume.data.length > MAX ? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)` : resume.data - await bot.editMessageText( `📋 Твоё резюме\n
${escapeHtml(text)}
`, { @@ -331,7 +294,7 @@ export function registerHHCommands() { }) const resumes = await listResumes(chatId) if (resumes.length === 0) { - await showResult(chatId, messageId, '😬 Резюме не найдены. Создайте резюме на hh.ru') + await showResult(chatId, messageId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') } else if (resumes.length === 1) { await bot.editMessageText('🔄 Сохраняю резюме...', { @@ -354,7 +317,7 @@ export function registerHHCommands() { const idx = Number(query.data.replace('hh_resume_pick_', '')) const resume = state.pendingResumes[idx] if (!resume) { - await showResult(chatId, messageId, '😬 Резюме не найдено, попробуйте снова') + await showResult(chatId, messageId, '❌ Резюме не найдено, попробуйте снова') break } await bot.editMessageText('🔄 Сохраняю резюме...', { @@ -410,7 +373,7 @@ export function registerHHCommands() { if (state.awaitingMax) { const num = Number(msg.text) if (Number.isNaN(num) || num < 1 || num > 50) { - await bot.sendMessage(chatId, '😬 Введи число от 1 до 50:') + await bot.sendMessage(chatId, '❌ Введи число от 1 до 50:') return } state.awaitingMax = false diff --git a/src/hh/browser.ts b/src/hh/browser.ts new file mode 100644 index 0000000..4da3ab5 --- /dev/null +++ b/src/hh/browser.ts @@ -0,0 +1,27 @@ +import prisma from '@prisma' +import { type Browser, chromium, type Page } from 'playwright' + +export function randomDelay(min = 300, max = 2000): number { + return min + Math.random() * (max - min) +} + +export async function humanDelay(min = 300, max = 2000): Promise { + return new Promise(r => setTimeout(r, randomDelay(min, max))) +} + +export async function randomScroll(page: Page): Promise { + await page.mouse.move(100 + Math.random() * 500, 200 + Math.random() * 500) + await page.mouse.wheel(0, 300 + Math.random() * 1000) +} + +export async function getBrowser(): Promise { + return chromium.launch({ headless: false }) +} + +export async function loadSession(page: Page, telegramId: bigint | number): Promise { + const user = await prisma.user.findUnique({ where: { telegramId } }) + if (!user?.session) + return false + await page.context().addCookies(JSON.parse(user.session)) + return true +} diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index e89f701..c114aae 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -1,153 +1,71 @@ import type { Message } from 'node-telegram-bot-api' +import type { ApplyOptions, ApplyResult, ResumeListItem, VacancyRef } from './types.js' +import type { StatusReporter } from './ui.js' import bot from '@bot' import prisma from '@prisma' -import { type Browser, chromium, type Page } from 'playwright' import { createMessage } from '../openai' - -// const SESSION_FILE = path.resolve('./session.json') - -interface ApplyOptions { - query: string - area?: number - maxApplies?: number -} - -interface VacancyRef { - title: string - href: string -} - -interface ApplyResult { - applied: VacancyRef[] - skipped: VacancyRef[] - errors: Array - 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 { - return chromium.launch({ headless: false }) -} - -async function loadSession(page: Page, telegramId: bigint | number): Promise { - 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 -} +import { getBrowser, loadSession, randomDelay } from './browser.js' function waitForOtp(chatId: number): Promise { return new Promise((resolve) => { const handler = (msg: Message) => { - if (msg.chat.id !== chatId) + if (msg.chat.id !== chatId || !msg.text) 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 { +export async function login(email: string, chatId: number): Promise { const browser = await getBrowser() - await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`) - if (!browser.version()) + // await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`) + if (!browser.version()) { + console.log('browser error') 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 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 bot.sendMessage(chatId, `Клик по "Email"`) await page.fill('[data-qa="applicant-login-input-email"]', email) - await page.waitForTimeout(randomDelay()) - - await bot.sendMessage(chatId, `Ввод "Email"`) + // await bot.sendMessage(chatId, `Ввод "Email"`) await page.click('[data-qa="submit-button"]') - - await bot.sendMessage(chatId, `Клик по "Дальше"`) - + // 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 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), - }, + data: { session: JSON.stringify(cookies, null, 2) }, }) - if (cookies.length > 0) { - await bot.sendMessage(chatId, `✅ Авторизация выполнена`) - } - else { - await bot.sendMessage(chatId, `😬 Произошла ошибка`) - } - + await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка') await browser.close() } @@ -155,27 +73,14 @@ 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' }) - + await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'networkidle' }) try { return await page.$('[data-qa="profileAndResumes-button"]') } catch (e) { return e } - // return await page.$('[data-qa="mainmenu_createResume"]') -} - -export interface ResumeListItem { - title: string - href: string } export async function listResumes(chatId: number): Promise { @@ -219,7 +124,7 @@ export async function saveResume(chatId: number, resumeHref: string): Promise { +export async function applyToJobs( + { query, area = 1, maxApplies = 10 }: ApplyOptions, + { chatId, reporter }: { chatId: number, reporter: StatusReporter }, +): Promise { const browser = await getBrowser() const context = await browser.newContext() const page = await context.newPage() const results: ApplyResult = { applied: [], skipped: [], errors: [] } - - let statusMsgId: number | null = null - - async function status(text: string): Promise { - if (statusMsgId) { - await bot.deleteMessage(chatId, statusMsgId).catch(() => {}) - statusMsgId = null - } - const msg = await bot.sendMessage(chatId, text) - statusMsgId = msg.message_id - } - - async function keep(text: string): Promise { - if (statusMsgId) { - await bot.deleteMessage(chatId, statusMsgId).catch(() => {}) - statusMsgId = null - } - await bot.sendMessage(chatId, text, { parse_mode: 'HTML' }) - } + const { status, keep, clear } = reporter try { await loadSession(page, chatId) @@ -265,12 +149,11 @@ export async function applyToJobs({ 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"]') - if (!isLoggedIn) { + if (!await page.$('[data-qa="profileAndResumes-button"]')) { return { ...results, error: 'Не авторизован. Выполните login' } } - await status(`✅ Авторизация выполнена`) + await status('✅ Авторизация выполнена') const vacancies = await page.$$eval( '[data-qa="serp-item__title"]', @@ -309,7 +192,6 @@ export async function applyToJobs({ await status(`✍️ Генерирую письмо: ${vacancy.title}`) const letter = await createMessage(resume.data, description, user!.prompt) - await keep(`✅ ${vacancy.title}\n\n${letter}`) results.applied.push(ref) @@ -319,10 +201,7 @@ export async function applyToJobs({ } } - if (statusMsgId) { - await bot.deleteMessage(chatId, statusMsgId).catch(() => {}) - statusMsgId = null - } + await clear() } finally { await browser.close() diff --git a/src/hh/types.ts b/src/hh/types.ts new file mode 100644 index 0000000..52b087d --- /dev/null +++ b/src/hh/types.ts @@ -0,0 +1,22 @@ +export interface ApplyOptions { + query: string + area?: number + maxApplies?: number +} + +export interface VacancyRef { + title: string + href: string +} + +export interface ApplyResult { + applied: VacancyRef[] + skipped: VacancyRef[] + errors: Array + error?: string +} + +export interface ResumeListItem { + title: string + href: string +} diff --git a/src/hh/ui.ts b/src/hh/ui.ts new file mode 100644 index 0000000..6ad93da --- /dev/null +++ b/src/hh/ui.ts @@ -0,0 +1,77 @@ +import bot from '@bot' + +export const MAIN_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' }, + ], + [ + { text: '📄 Выбрать резюме', callback_data: 'hh_resume_list' }, + { text: '📋 Моё резюме', callback_data: 'hh_my_resume' }, + ], + ], +} + +export const LOGIN_MARKUP = { + inline_keyboard: [ + [{ text: '🔑 Войти через hh.ru', callback_data: 'hh_login' }], + ], +} + +export const BACK_MARKUP = { + inline_keyboard: [[{ text: '◀️ Назад', callback_data: 'hh_back' }]], +} + +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +export async function showResult(chatId: number, messageId: number, text: string): Promise { + await bot.editMessageText(text, { + chat_id: chatId, + message_id: messageId, + reply_markup: BACK_MARKUP, + }) +} + +export interface StatusReporter { + status: (text: string) => Promise + keep: (text: string) => Promise + clear: () => Promise +} + +export function createStatusReporter(chatId: number, initialMsgId?: number | null): StatusReporter { + let msgId: number | null = initialMsgId ?? null + + async function deleteCurrent(): Promise { + if (msgId) { + await bot.deleteMessage(chatId, msgId).catch(() => {}) + msgId = null + } + } + + return { + async status(text): Promise { + await deleteCurrent() + const msg = await bot.sendMessage(chatId, text) + msgId = msg.message_id + }, + async keep(text): Promise { + await deleteCurrent() + await bot.sendMessage(chatId, text, { parse_mode: 'HTML' }) + }, + async clear(): Promise { + await deleteCurrent() + }, + } +} diff --git a/src/index.ts b/src/index.ts index eb33e2c..3a922c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ import bot from '@bot' -import prisma from '@prisma' -import { triggerHHStart } from './hh/bot-commands.js' -import { registerHHCommands } from './hh/bot-commands.js' +import prisma from '@prisma' +import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js' registerHHCommands() diff --git a/src/openai.ts b/src/openai.ts index b9e53a8..b9c70d0 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -1,3 +1,4 @@ +import process from 'node:process' import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk' // import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai'