mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-06-08 18:04:57 +00:00
🚀 feat(file): добавлено свойство awaitingPrompt для ожидания ввода промта.
All checks were successful
Deploy / deploy (push) Successful in 3m21s
All checks were successful
Deploy / deploy (push) Successful in 3m21s
This commit is contained in:
19
prisma/migrations/20260528201931_promt_update/migration.sql
Normal file
19
prisma/migrations/20260528201931_promt_update/migration.sql
Normal 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;
|
||||
@@ -24,7 +24,7 @@ model User {
|
||||
session String?
|
||||
hhEmail String?
|
||||
resumes Resume[]
|
||||
prompt String @default("Напиши сопроводительное письмо опираясь на резюме. Пиши грамотно и коротко, простым языком не официально. Пиши только текст самого письма, ты пишешь напрямую в компанию. Мне отвечать или делать ремарки не нужно.")
|
||||
prompt String @default("Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.")
|
||||
Settings Settings?
|
||||
}
|
||||
|
||||
|
||||
5
src/config.ts
Normal file
5
src/config.ts
Normal 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,
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<pre>${escapeHtml(user.prompt)}</pre>` : ''
|
||||
await bot.sendMessage(chatId, `📝 Введи новый промт для AI:${current}`, { parse_mode: 'HTML' })
|
||||
break
|
||||
}
|
||||
|
||||
case BTN.LOGIN:
|
||||
await handleLogin(chatId)
|
||||
break
|
||||
|
||||
@@ -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<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 {
|
||||
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<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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
const browser = await getBrowser()
|
||||
// await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`)
|
||||
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<void> {
|
||||
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,14 +102,11 @@ export async function login(email: string, chatId: number): Promise<void> {
|
||||
})
|
||||
|
||||
await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка')
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkIsAuth(telegramId: bigint | number) {
|
||||
const browser = await getBrowser()
|
||||
return withBrowser(async (browser) => {
|
||||
const context = await newStealthContext(browser)
|
||||
const page = await context.newPage()
|
||||
await loadSession(page, telegramId)
|
||||
@@ -127,13 +117,11 @@ export async function checkIsAuth(telegramId: bigint | number) {
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
|
||||
const browser = await getBrowser()
|
||||
return withBrowser(async (browser) => {
|
||||
const context = await newStealthContext(browser)
|
||||
const page = await context.newPage()
|
||||
await loadSession(page, chatId)
|
||||
@@ -160,7 +148,7 @@ export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
|
||||
links => links.map((a) => {
|
||||
const card = a.parentElement
|
||||
const titleEl = card?.querySelector('[data-qa="resume-title"]') ?? card?.querySelector('[data-qa="title"]')
|
||||
console.log(titleEl)
|
||||
// console.log(titleEl)
|
||||
return {
|
||||
href: (a as HTMLAnchorElement).getAttribute('href') ?? '',
|
||||
title: titleEl?.innerText?.trim() ?? '(Ошибка в получении названия)',
|
||||
@@ -177,19 +165,15 @@ export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
|
||||
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`
|
||||
@@ -207,7 +191,6 @@ export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
console.log(items)
|
||||
return items
|
||||
}
|
||||
@@ -220,8 +203,8 @@ export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
throw lastError!
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveResume(chatId: number, resumeItem: ResumeListItem): Promise<void> {
|
||||
@@ -236,13 +219,12 @@ export async function applyToJobs(
|
||||
{ query, area = 1, maxApplies = 10 }: ApplyOptions,
|
||||
{ chatId, reporter }: { chatId: number, reporter: StatusReporter },
|
||||
): Promise<ApplyResult> {
|
||||
const browser = await getBrowser()
|
||||
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<null>((_, reject) =>
|
||||
@@ -436,11 +418,8 @@ export async function applyToJobs(
|
||||
}
|
||||
}
|
||||
|
||||
await clear()
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
// await clear()
|
||||
|
||||
return results
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
Reference in New Issue
Block a user