🚀 feat(file): добавлено свойство awaitingPrompt для ожидания ввода промта.
All checks were successful
Deploy / deploy (push) Successful in 3m21s

This commit is contained in:
Oscar
2026-05-28 23:45:24 +03:00
parent 124302c661
commit 956551e30e
8 changed files with 214 additions and 136 deletions

View File

@@ -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;

View File

@@ -24,7 +24,7 @@ model User {
session String? session String?
hhEmail String? hhEmail String?
resumes Resume[] resumes Resume[]
prompt String @default("Напиши сопроводительное письмо опираясь на резюме. Пиши грамотно и коротко, простым языком не официально. Пиши только текст самого письма, ты пишешь напрямую в компанию. Мне отвечать или делать ремарки не нужно.") prompt String @default("Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.")
Settings Settings? Settings Settings?
} }

5
src/config.ts Normal file
View File

@@ -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,
}

View File

@@ -10,6 +10,7 @@ interface UserState {
awaitingEmail: boolean awaitingEmail: boolean
awaitingQuery: boolean awaitingQuery: boolean
awaitingMax: boolean awaitingMax: boolean
awaitingPrompt: boolean
pendingResumes: ResumeListItem[] pendingResumes: ResumeListItem[]
loginPromptMessageId: number | null loginPromptMessageId: number | null
} }
@@ -20,6 +21,7 @@ function makeUserState(): UserState {
awaitingEmail: false, awaitingEmail: false,
awaitingQuery: false, awaitingQuery: false,
awaitingMax: false, awaitingMax: false,
awaitingPrompt: false,
pendingResumes: [], pendingResumes: [],
loginPromptMessageId: null, loginPromptMessageId: null,
} }
@@ -84,7 +86,7 @@ async function handleApply(chatId: number): Promise<void> {
return return
const reporter = createStatusReporter(chatId) const reporter = createStatusReporter(chatId)
await reporter.status(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) await reporter.keep(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`)
applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter }) applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter })
.then(async (result) => { .then(async (result) => {
@@ -375,6 +377,17 @@ export function registerHHCommands() {
return 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) { switch (msg.text) {
case BTN.APPLY: case BTN.APPLY:
await handleApply(chatId) await handleApply(chatId)
@@ -425,6 +438,14 @@ export function registerHHCommands() {
await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD })
break break
case BTN.PROMPT: {
state.awaitingPrompt = true
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
const current = user?.prompt ? `\n\nТекущий:\n<pre>${escapeHtml(user.prompt)}</pre>` : ''
await bot.sendMessage(chatId, `📝 Введи новый промт для AI:${current}`, { parse_mode: 'HTML' })
break
}
case BTN.LOGIN: case BTN.LOGIN:
await handleLogin(chatId) await handleLogin(chatId)
break break

View File

@@ -1,6 +1,47 @@
import process from 'node:process' import process from 'node:process'
import prisma from '@prisma' import prisma from '@prisma'
import { type Browser, chromium, type Page } from 'playwright' 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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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 { export function randomDelay(min = 300, max = 2000): number {
return min + Math.random() * (max - min) 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)) await page.context().addCookies(JSON.parse(user.session))
return true return true
} }
export async function withBrowser<T>(fn: (browser: Browser) => Promise<T>): Promise<T> {
return browserQueue.run(async () => {
const browser = await getBrowser()
try {
return await fn(browser)
}
finally {
await browser.close()
}
})
}

View File

@@ -5,8 +5,8 @@ import type { StatusReporter } from './ui.js'
import bot from '@bot' import bot from '@bot'
import prisma from '@prisma' import prisma from '@prisma'
import { createMessage } from '@/openai' import { createMessage } from '@/openai'
import { getBrowser, loadSession, newStealthContext, randomDelay, randomScroll } from './browser.js' import { loadSession, newStealthContext, randomDelay, randomScroll, withBrowser } from './browser.js'
import { escapeHtml } from './ui.js' import { createStatusReporter, escapeHtml } from './ui.js'
export class NoResumeError extends Error { export class NoResumeError extends Error {
constructor() { constructor() {
@@ -49,7 +49,8 @@ async function skipIfQuestionnaire(
const hasQuestionnaire = await page.$('[data-qa="employer-asking-for-test"], [data-qa="task-body"]') const hasQuestionnaire = await page.$('[data-qa="employer-asking-for-test"], [data-qa="task-body"]')
if (!hasQuestionnaire) if (!hasQuestionnaire)
return false return false
await status(`Пропущена вакансия: ${vacancy.title}`) const { keep } = createStatusReporter(chatId)
await keep(`Пропущена вакансия: ${vacancy.title}`)
console.log(`[x] ${vacancy.title} hasQuestionnaire`) console.log(`[x] ${vacancy.title} hasQuestionnaire`)
await prisma.skippedVacancy.upsert({ await prisma.skippedVacancy.upsert({
where: { telegramId_href: { telegramId: chatId, href: vacancy.href } }, where: { telegramId_href: { telegramId: chatId, href: vacancy.href } },
@@ -61,34 +62,27 @@ async function skipIfQuestionnaire(
} }
export async function login(email: string, chatId: number): Promise<void> { export async function login(email: string, chatId: number): Promise<void> {
const browser = await getBrowser() await withBrowser(async (browser) => {
// await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`) if (!browser.version()) {
if (!browser.version()) { console.log('browser error')
console.log('browser error') return
return }
}
try {
const context = await newStealthContext(browser) const context = await newStealthContext(browser)
const page = await context.newPage() const page = await context.newPage()
await page.goto('https://hh.ru/account/login', { waitUntil: 'domcontentloaded' }) 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.click('[data-qa="submit-button"]')
await page.waitForTimeout(randomDelay()) await page.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Клик по "Войти"`)
await page.click('label:has([data-qa="credential-type-EMAIL"])') await page.click('label:has([data-qa="credential-type-EMAIL"])')
await page.waitForTimeout(randomDelay()) await page.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Клик по "Email"`)
await page.fill('[data-qa="applicant-login-input-email"]', email) await page.fill('[data-qa="applicant-login-input-email"]', email)
await page.waitForTimeout(randomDelay()) await page.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Ввод "Email"`)
await page.click('[data-qa="submit-button"]') await page.click('[data-qa="submit-button"]')
// await bot.sendMessage(chatId, `Клик по "Дальше"`)
await page.waitForTimeout(randomDelay()) await page.waitForTimeout(randomDelay())
await bot.sendMessage(chatId, '🔑 Введи код из email') await bot.sendMessage(chatId, '🔑 Введи код из email')
@@ -97,7 +91,6 @@ export async function login(email: string, chatId: number): Promise<void> {
await page.click('[data-qa="applicant-login-input-otp"]') await page.click('[data-qa="applicant-login-input-otp"]')
const otp = await waitForOtp(chatId) const otp = await waitForOtp(chatId)
await page.fill('[data-qa="applicant-login-input-otp"] input', otp) await page.fill('[data-qa="applicant-login-input-otp"] input', otp)
// await bot.sendMessage(chatId, `Введён ОТП: ${otp}`)
await page.waitForTimeout(randomDelay()) await page.waitForTimeout(randomDelay())
await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 }) await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 })
@@ -109,119 +102,109 @@ export async function login(email: string, chatId: number): Promise<void> {
}) })
await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка') await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка')
} })
finally {
await browser.close()
}
} }
export async function checkIsAuth(telegramId: bigint | number) { export async function checkIsAuth(telegramId: bigint | number) {
const browser = await getBrowser() return withBrowser(async (browser) => {
const context = await newStealthContext(browser) const context = await newStealthContext(browser)
const page = await context.newPage() const page = await context.newPage()
await loadSession(page, telegramId) await loadSession(page, telegramId)
await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' }) await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' })
try { try {
return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 }) return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 })
} }
catch { catch {
return null return null
} }
finally { })
await browser.close()
}
} }
export async function listResumes(chatId: number): Promise<ResumeListItem[]> { export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
const browser = await getBrowser() return withBrowser(async (browser) => {
const context = await newStealthContext(browser) const context = await newStealthContext(browser)
const page = await context.newPage() const page = await context.newPage()
await loadSession(page, chatId) await loadSession(page, chatId)
let lastError: Error | null = null let lastError: Error | null = null
for (let attempt = 1; attempt <= 2; attempt++) { for (let attempt = 1; attempt <= 2; attempt++) {
try { try {
await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' }) await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' })
const finalUrl = page.url() const finalUrl = page.url()
if (finalUrl.includes('/profile/resume/professional_role')) { if (finalUrl.includes('/profile/resume/professional_role')) {
throw new NoResumeError() 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 },
})
} }
catch (e) { if (!finalUrl.includes('/applicant/resumes')) {
console.log(`Failed to fetch resume text for ${item.title}:`, e) 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<void> { export async function saveResume(chatId: number, resumeItem: ResumeListItem): Promise<void> {
@@ -236,13 +219,12 @@ export async function applyToJobs(
{ query, area = 1, maxApplies = 10 }: ApplyOptions, { query, area = 1, maxApplies = 10 }: ApplyOptions,
{ chatId, reporter }: { chatId: number, reporter: StatusReporter }, { chatId, reporter }: { chatId: number, reporter: StatusReporter },
): Promise<ApplyResult> { ): Promise<ApplyResult> {
const browser = await getBrowser() return withBrowser(async (browser) => {
const context = await newStealthContext(browser) const context = await newStealthContext(browser)
const page = await context.newPage() const page = await context.newPage()
const results: ApplyResult = { applied: [], skipped: [], errors: [] } const results: ApplyResult = { applied: [], skipped: [], errors: [] }
const { status, keep, clear } = reporter const { status, keep, clear } = reporter
try {
await loadSession(page, chatId) await loadSession(page, chatId)
const url = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&area=${area}` 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 resumes = await prisma.resume.findMany({ where: { telegramId: chatId } })
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
@@ -291,7 +273,7 @@ export async function applyToJobs(
} }
try { try {
await status(`🔄 Обрабатывается: ${vacancy.title}`) await keep(`🔄 Обрабатывается: ${vacancy.title}`)
await page.goto(vacancy.href, { waitUntil: 'domcontentloaded' }) await page.goto(vacancy.href, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-qa="vacancy-description"]', { timeout: 10000 }).catch(() => null) await page.waitForSelector('[data-qa="vacancy-description"]', { timeout: 10000 }).catch(() => null)
@@ -319,7 +301,7 @@ export async function applyToJobs(
continue continue
// console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt) // console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt)
await status(`✍️ Генерирую письмо: ${vacancy.title}`) await keep(`✍️ Генерирую письмо: ${vacancy.title}`)
const letterPromise = Promise.race([ const letterPromise = Promise.race([
createMessage(resume.data, description, user!.prompt), createMessage(resume.data, description, user!.prompt),
new Promise<null>((_, reject) => new Promise<null>((_, reject) =>
@@ -436,11 +418,8 @@ export async function applyToJobs(
} }
} }
await clear() // await clear()
}
finally {
await browser.close()
}
return results return results
})
} }

View File

@@ -13,6 +13,7 @@ export const BTN = {
SETTINGS: '⚙️ Настройки', SETTINGS: '⚙️ Настройки',
INFO: ' Информация', INFO: ' Информация',
BACK: '◀️ Назад', BACK: '◀️ Назад',
PROMPT: '📝 Промт',
} as const } as const
export const LOGIN_REPLY_KEYBOARD = { export const LOGIN_REPLY_KEYBOARD = {
@@ -34,7 +35,7 @@ export const SETTINGS_REPLY_KEYBOARD = {
keyboard: [ keyboard: [
[{ text: BTN.MAX }, { text: BTN.QUERY }], [{ text: BTN.MAX }, { text: BTN.QUERY }],
[{ text: BTN.AUTO_TOGGLE }, { text: BTN.RESUME_LIST }], [{ text: BTN.AUTO_TOGGLE }, { text: BTN.RESUME_LIST }],
[{ text: BTN.LOGIN }], [{ text: BTN.PROMPT }, { text: BTN.LOGIN }],
[{ text: BTN.BACK }], [{ text: BTN.BACK }],
], ],
resize_keyboard: true, resize_keyboard: true,

View File

@@ -91,7 +91,7 @@ export async function createMessage(resume: string, message: string, prompt?: st
console.log('[createMessage] sessionId: ', sessionId) console.log('[createMessage] sessionId: ', sessionId)
const finalPromt = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' const finalPromt = prompt || 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
const resumePreview = resume.slice(0, 200).replace(/\n/g, ' ') const resumePreview = resume.slice(0, 200).replace(/\n/g, ' ')
console.log(`\n${'─'.repeat(60)}`) console.log(`\n${'─'.repeat(60)}`)