diff --git a/prisma/migrations/20260528201931_promt_update/migration.sql b/prisma/migrations/20260528201931_promt_update/migration.sql new file mode 100644 index 0000000..9851db5 --- /dev/null +++ b/prisma/migrations/20260528201931_promt_update/migration.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "telegramId" BIGINT NOT NULL, + "username" TEXT, + "firstName" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "session" TEXT, + "hhEmail" TEXT, + "prompt" TEXT NOT NULL DEFAULT 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' +); +INSERT INTO "new_User" ("createdAt", "firstName", "hhEmail", "id", "prompt", "session", "telegramId", "username") SELECT "createdAt", "firstName", "hhEmail", "id", "prompt", "session", "telegramId", "username" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5799a01..306ab85 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,7 +24,7 @@ model User { session String? hhEmail String? resumes Resume[] - prompt String @default("Напиши сопроводительное письмо опираясь на резюме. Пиши грамотно и коротко, простым языком не официально. Пиши только текст самого письма, ты пишешь напрямую в компанию. Мне отвечать или делать ремарки не нужно.") + prompt String @default("Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.") Settings Settings? } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d30d58f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +export const config = { + // Maximum number of Playwright browser instances running simultaneously. + // Each instance uses ~100-200 MB RAM. Increase only if server can handle it. + maxConcurrentBrowsers: 3, +} diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 1f1190b..00328a0 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -10,6 +10,7 @@ interface UserState { awaitingEmail: boolean awaitingQuery: boolean awaitingMax: boolean + awaitingPrompt: boolean pendingResumes: ResumeListItem[] loginPromptMessageId: number | null } @@ -20,6 +21,7 @@ function makeUserState(): UserState { awaitingEmail: false, awaitingQuery: false, awaitingMax: false, + awaitingPrompt: false, pendingResumes: [], loginPromptMessageId: null, } @@ -84,7 +86,7 @@ async function handleApply(chatId: number): Promise { return const reporter = createStatusReporter(chatId) - await reporter.status(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) + await reporter.keep(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter }) .then(async (result) => { @@ -375,6 +377,17 @@ export function registerHHCommands() { return } + if (state.awaitingPrompt) { + state.awaitingPrompt = false + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + await prisma.user.update({ + where: { telegramId: chatId }, + data: { prompt: msg.text }, + }) + await bot.sendMessage(chatId, '✅ Промт сохранён') + return + } + switch (msg.text) { case BTN.APPLY: await handleApply(chatId) @@ -425,6 +438,14 @@ export function registerHHCommands() { await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) break + case BTN.PROMPT: { + state.awaitingPrompt = true + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + const current = user?.prompt ? `\n\nТекущий:\n
${escapeHtml(user.prompt)}
` : '' + await bot.sendMessage(chatId, `📝 Введи новый промт для AI:${current}`, { parse_mode: 'HTML' }) + break + } + case BTN.LOGIN: await handleLogin(chatId) break diff --git a/src/hh/browser.ts b/src/hh/browser.ts index ead532d..0851a47 100644 --- a/src/hh/browser.ts +++ b/src/hh/browser.ts @@ -1,6 +1,47 @@ import process from 'node:process' import prisma from '@prisma' import { type Browser, chromium, type Page } from 'playwright' +import { config } from '@/config.js' + +class Semaphore { + private running = 0 + private readonly queue: Array<() => void> = [] + + constructor(private readonly max: number) {} + + private acquire(): Promise { + if (this.running < this.max) { + this.running++ + return Promise.resolve() + } + return new Promise(resolve => this.queue.push(resolve)) + } + + private release(): void { + this.running-- + const next = this.queue.shift() + if (next) { + this.running++ + next() + } + } + + async run(fn: () => Promise): Promise { + await this.acquire() + try { + return await fn() + } + finally { + this.release() + } + } + + get stats() { + return { running: this.running, queued: this.queue.length, max: this.max } + } +} + +export const browserQueue = new Semaphore(config.maxConcurrentBrowsers) export function randomDelay(min = 300, max = 2000): number { return min + Math.random() * (max - min) @@ -48,3 +89,15 @@ export async function loadSession(page: Page, telegramId: bigint | number): Prom await page.context().addCookies(JSON.parse(user.session)) return true } + +export async function withBrowser(fn: (browser: Browser) => Promise): Promise { + return browserQueue.run(async () => { + const browser = await getBrowser() + try { + return await fn(browser) + } + finally { + await browser.close() + } + }) +} diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index a675cdd..a10f839 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -5,8 +5,8 @@ import type { StatusReporter } from './ui.js' import bot from '@bot' import prisma from '@prisma' import { createMessage } from '@/openai' -import { getBrowser, loadSession, newStealthContext, randomDelay, randomScroll } from './browser.js' -import { escapeHtml } from './ui.js' +import { loadSession, newStealthContext, randomDelay, randomScroll, withBrowser } from './browser.js' +import { createStatusReporter, escapeHtml } from './ui.js' export class NoResumeError extends Error { constructor() { @@ -49,7 +49,8 @@ async function skipIfQuestionnaire( const hasQuestionnaire = await page.$('[data-qa="employer-asking-for-test"], [data-qa="task-body"]') if (!hasQuestionnaire) return false - await status(`Пропущена вакансия: ${vacancy.title}`) + const { keep } = createStatusReporter(chatId) + await keep(`Пропущена вакансия: ${vacancy.title}`) console.log(`[x] ${vacancy.title} hasQuestionnaire`) await prisma.skippedVacancy.upsert({ where: { telegramId_href: { telegramId: chatId, href: vacancy.href } }, @@ -61,34 +62,27 @@ async function skipIfQuestionnaire( } 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()) { - console.log('browser error') - return - } + await withBrowser(async (browser) => { + if (!browser.version()) { + console.log('browser error') + return + } - try { const context = await newStealthContext(browser) const page = await context.newPage() await page.goto('https://hh.ru/account/login', { waitUntil: 'domcontentloaded' }) - // 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.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') @@ -97,7 +91,6 @@ export async function login(email: string, chatId: number): Promise { 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.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 }) @@ -109,119 +102,109 @@ export async function login(email: string, chatId: number): Promise { }) await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка') - } - finally { - await browser.close() - } + }) } export async function checkIsAuth(telegramId: bigint | number) { - const browser = await getBrowser() - const context = await newStealthContext(browser) - const page = await context.newPage() - await loadSession(page, telegramId) - await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' }) - try { - return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 }) - } - catch { - return null - } - finally { - await browser.close() - } + return withBrowser(async (browser) => { + const context = await newStealthContext(browser) + const page = await context.newPage() + await loadSession(page, telegramId) + await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' }) + try { + return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 }) + } + catch { + return null + } + }) } export async function listResumes(chatId: number): Promise { - const browser = await getBrowser() - const context = await newStealthContext(browser) - const page = await context.newPage() - await loadSession(page, chatId) + return withBrowser(async (browser) => { + const context = await newStealthContext(browser) + const page = await context.newPage() + await loadSession(page, chatId) - let lastError: Error | null = null - for (let attempt = 1; attempt <= 2; attempt++) { - try { - await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' }) - const finalUrl = page.url() - if (finalUrl.includes('/profile/resume/professional_role')) { - throw new NoResumeError() - } - if (!finalUrl.includes('/applicant/resumes')) { - throw new Error(`Session expired or redirected: ${finalUrl}`) - } - await page.waitForSelector('[data-qa^="resume-card-link-"]', { timeout: 10000 }) - - const cardLinks = await page.$$('[data-qa^="resume-card-link-"]') - - let items: ResumeListItem[] - if (cardLinks.length > 1) { - items = await page.$$eval( - '[data-qa^="resume-card-link-"]', - links => links.map((a) => { - const card = a.parentElement - const titleEl = card?.querySelector('[data-qa="resume-title"]') ?? card?.querySelector('[data-qa="title"]') - console.log(titleEl) - return { - href: (a as HTMLAnchorElement).getAttribute('href') ?? '', - title: titleEl?.innerText?.trim() ?? '(Ошибка в получении названия)', - } - }), - ) - - console.log(items.length) - } - else { - const href = await cardLinks[0].getAttribute('href') ?? '' - const titleEl = await page.$('[data-qa="resume-title"] h3') ?? await page.$('[data-qa="title"]') - const title = (await titleEl?.innerText())?.trim() ?? '(без названия)' - items = [{ href, title }] - } - - // Sync with DB - const hhIds = items.map(item => new URL(`https://hh.ru${item.href}`).pathname.split('/').pop()!) - - // Delete resumes removed from hh.ru - await prisma.resume.deleteMany({ where: { telegramId: chatId, id: { notIn: hhIds } } }) - - // If selected resume was deleted — reset selection - const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) - if (settings?.selectedResumeId && !hhIds.includes(settings.selectedResumeId)) { - await prisma.settings.update({ where: { telegramId: chatId }, data: { selectedResumeId: null } }) - } - - // Fetch text and upsert each resume - for (const item of items) { - const id = new URL(`https://hh.ru${item.href}`).pathname.split('/').pop()! - const resumeUrl = `https://hh.ru/resume_converter/resume.txt?hash=${id}&type=txt&hhtmFrom=&hhtmSource=resume` - await page.goto(resumeUrl, { waitUntil: 'load' }) - try { - const data = await page.locator('.resume').innerText() - await prisma.resume.upsert({ - where: { id }, - create: { data, id, telegramId: chatId, title: item.title }, - update: { data, title: item.title }, - }) + let lastError: Error | null = null + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' }) + const finalUrl = page.url() + if (finalUrl.includes('/profile/resume/professional_role')) { + throw new NoResumeError() } - catch (e) { - console.log(`Failed to fetch resume text for ${item.title}:`, e) + if (!finalUrl.includes('/applicant/resumes')) { + throw new Error(`Session expired or redirected: ${finalUrl}`) } + await page.waitForSelector('[data-qa^="resume-card-link-"]', { timeout: 10000 }) + + const cardLinks = await page.$$('[data-qa^="resume-card-link-"]') + + let items: ResumeListItem[] + if (cardLinks.length > 1) { + items = await page.$$eval( + '[data-qa^="resume-card-link-"]', + links => links.map((a) => { + const card = a.parentElement + const titleEl = card?.querySelector('[data-qa="resume-title"]') ?? card?.querySelector('[data-qa="title"]') + // console.log(titleEl) + return { + href: (a as HTMLAnchorElement).getAttribute('href') ?? '', + title: titleEl?.innerText?.trim() ?? '(Ошибка в получении названия)', + } + }), + ) + + console.log(items.length) + } + else { + const href = await cardLinks[0].getAttribute('href') ?? '' + const titleEl = await page.$('[data-qa="resume-title"] h3') ?? await page.$('[data-qa="title"]') + const title = (await titleEl?.innerText())?.trim() ?? '(без названия)' + items = [{ href, title }] + } + + const hhIds = items.map(item => new URL(`https://hh.ru${item.href}`).pathname.split('/').pop()!) + + await prisma.resume.deleteMany({ where: { telegramId: chatId, id: { notIn: hhIds } } }) + + const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) + if (settings?.selectedResumeId && !hhIds.includes(settings.selectedResumeId)) { + await prisma.settings.update({ where: { telegramId: chatId }, data: { selectedResumeId: null } }) + } + + for (const item of items) { + const id = new URL(`https://hh.ru${item.href}`).pathname.split('/').pop()! + const resumeUrl = `https://hh.ru/resume_converter/resume.txt?hash=${id}&type=txt&hhtmFrom=&hhtmSource=resume` + await page.goto(resumeUrl, { waitUntil: 'load' }) + try { + const data = await page.locator('.resume').innerText() + await prisma.resume.upsert({ + where: { id }, + create: { data, id, telegramId: chatId, title: item.title }, + update: { data, title: item.title }, + }) + } + catch (e) { + console.log(`Failed to fetch resume text for ${item.title}:`, e) + } + } + + console.log(items) + return items + } + catch (e) { + if (e instanceof NoResumeError) + throw e + lastError = e as Error + if (attempt < 2) + await page.waitForTimeout(4000) } - - await browser.close() - console.log(items) - return items } - catch (e) { - if (e instanceof NoResumeError) - throw e - lastError = e as Error - if (attempt < 2) - await page.waitForTimeout(4000) - } - } - await browser.close() - throw lastError! + throw lastError! + }) } export async function saveResume(chatId: number, resumeItem: ResumeListItem): Promise { @@ -236,13 +219,12 @@ export async function applyToJobs( { query, area = 1, maxApplies = 10 }: ApplyOptions, { chatId, reporter }: { chatId: number, reporter: StatusReporter }, ): Promise { - const browser = await getBrowser() - const context = await newStealthContext(browser) - const page = await context.newPage() - const results: ApplyResult = { applied: [], skipped: [], errors: [] } - const { status, keep, clear } = reporter + return withBrowser(async (browser) => { + const context = await newStealthContext(browser) + const page = await context.newPage() + const results: ApplyResult = { applied: [], skipped: [], errors: [] } + const { status, keep, clear } = reporter - try { await loadSession(page, chatId) const url = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&area=${area}` @@ -263,7 +245,7 @@ export async function applyToJobs( })), ) - await status(`✅ Вакансий найдено: ${vacancies.length}`) + await keep(`✅ Вакансий найдено: ${vacancies.length}`) const resumes = await prisma.resume.findMany({ where: { telegramId: chatId } }) const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) @@ -291,7 +273,7 @@ export async function applyToJobs( } try { - await status(`🔄 Обрабатывается: ${vacancy.title}`) + await keep(`🔄 Обрабатывается: ${vacancy.title}`) await page.goto(vacancy.href, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-qa="vacancy-description"]', { timeout: 10000 }).catch(() => null) @@ -319,7 +301,7 @@ export async function applyToJobs( continue // console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt) - await status(`✍️ Генерирую письмо: ${vacancy.title}`) + await keep(`✍️ Генерирую письмо: ${vacancy.title}`) const letterPromise = Promise.race([ createMessage(resume.data, description, user!.prompt), new Promise((_, reject) => @@ -436,11 +418,8 @@ export async function applyToJobs( } } - await clear() - } - finally { - await browser.close() - } + // await clear() - return results + return results + }) } diff --git a/src/hh/ui.ts b/src/hh/ui.ts index cf16126..4efacc6 100644 --- a/src/hh/ui.ts +++ b/src/hh/ui.ts @@ -13,6 +13,7 @@ export const BTN = { SETTINGS: '⚙️ Настройки', INFO: 'ℹ️ Информация', BACK: '◀️ Назад', + PROMPT: '📝 Промт', } as const export const LOGIN_REPLY_KEYBOARD = { @@ -34,7 +35,7 @@ export const SETTINGS_REPLY_KEYBOARD = { keyboard: [ [{ text: BTN.MAX }, { text: BTN.QUERY }], [{ text: BTN.AUTO_TOGGLE }, { text: BTN.RESUME_LIST }], - [{ text: BTN.LOGIN }], + [{ text: BTN.PROMPT }, { text: BTN.LOGIN }], [{ text: BTN.BACK }], ], resize_keyboard: true, diff --git a/src/openai.ts b/src/openai.ts index 2a0284a..e2e628e 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -91,7 +91,7 @@ export async function createMessage(resume: string, message: string, prompt?: st console.log('[createMessage] sessionId: ', sessionId) - const finalPromt = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' + const finalPromt = prompt || 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' const resumePreview = resume.slice(0, 200).replace(/\n/g, ' ') console.log(`\n${'─'.repeat(60)}`)