From a9de783891353edf5c33f421e7942c28fc2a923e Mon Sep 17 00:00:00 2001 From: Oscar Date: Thu, 28 May 2026 12:08:48 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(file):=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20skipIfQuestionnaire=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=BA=D0=B5=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B8?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot-singleton.ts | 6 +++ src/hh/scraper.ts | 93 +++++++++++++++++++++++++++++--------------- src/index.ts | 7 ++-- src/openai.ts | 34 ++++++++++++++-- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/src/bot-singleton.ts b/src/bot-singleton.ts index 5c6ad91..841ed07 100644 --- a/src/bot-singleton.ts +++ b/src/bot-singleton.ts @@ -5,4 +5,10 @@ import TelegramBot from 'node-telegram-bot-api' const token = process.env.TG_BOT_TOKEN! const bot = new TelegramBot(token, { polling: true }) +bot.on('polling_error', (err: any) => { + // EFATAL (socket hang up) — Telegram обрывает long-poll соединение, это нормально + if (err?.code === 'EFATAL') return + console.error('[polling_error]', err?.code, err?.message) +}) + export default bot diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index da199f8..2933b64 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -1,9 +1,10 @@ import type { Message } from 'node-telegram-bot-api' +import type { Page } from 'playwright' import type { ApplyOptions, ApplyResult, ResumeListItem, VacancyRef } from './types.js' import type { StatusReporter } from './ui.js' import bot from '@bot' import prisma from '@prisma' -import { createMessage } from '../openai' +import { createMessage } from '@/openai' import { getBrowser, loadSession, randomDelay, randomScroll } from './browser.js' import { escapeHtml } from './ui.js' @@ -19,6 +20,39 @@ function waitForOtp(chatId: number): Promise { }) } +const APPLY_OUTCOME_SELECTOR = [ + '[data-qa="employer-asking-for-test"]', + '[data-qa="task-body"]', + '[data-qa="vacancy-response-popup-form-letter-input"]', + '[data-qa="vacancy-response-submit-popup"]', + '[data-qa="vacancy-response-letter-submit"]', + '[data-qa="vacancy-response-letter-toggle"]', + '[data-qa="textarea-wrapper"]', +].join(', ') + +async function skipIfQuestionnaire( + page: Page, + vacancy: { title: string, href: string }, + ref: VacancyRef, + chatId: number, + status: (msg: string) => Promise, + results: ApplyResult, +): Promise { + await page.waitForSelector(APPLY_OUTCOME_SELECTOR, { timeout: 5000 }).catch(() => {}) + const hasQuestionnaire = await page.$('[data-qa="employer-asking-for-test"], [data-qa="task-body"]') + if (!hasQuestionnaire) + return false + await status(`Пропущена вакансия: ${vacancy.title}`) + console.log(`[x] ${vacancy.title} hasQuestionnaire`) + await prisma.skippedVacancy.upsert({ + where: { telegramId_href: { telegramId: chatId, href: vacancy.href } }, + create: { telegramId: chatId, href: vacancy.href, title: vacancy.title }, + update: {}, + }) + results.skipped.push(ref) + return true +} + export async function login(email: string, chatId: number): Promise { const browser = await getBrowser() // await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`) @@ -248,11 +282,6 @@ export async function applyToJobs( continue } - await status(`✍️ Генерирую письмо: ${vacancy.title}`) - - console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt) - const letterPromise = createMessage(resume.data, description, user!.prompt) - const applyBtn = await page.$('[data-qa="vacancy-response-link-top"]') if (!applyBtn) { results.skipped.push(vacancy) @@ -262,20 +291,21 @@ export async function applyToJobs( await randomScroll(page) await applyBtn.click() - await page.waitForLoadState('domcontentloaded').catch(() => {}) - await page.waitForTimeout(randomDelay()) - const hasQuestionnaire = await page.$('[data-qa="employer-asking-for-test"]').catch(() => null) - if (hasQuestionnaire) { - console.log(`[x] ${vacancy.title} hasQuestionnaire`) - await prisma.skippedVacancy.upsert({ - where: { telegramId_href: { telegramId: chatId, href: vacancy.href } }, - create: { telegramId: chatId, href: vacancy.href, title: vacancy.title }, - update: {}, - }) - results.skipped.push(ref) + if (await skipIfQuestionnaire(page, vacancy, ref, chatId, status, results)) continue - } + + // console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt) + await status(`✍️ Генерирую письмо: ${vacancy.title}`) + const letterPromise = Promise.race([ + createMessage(resume.data, description, user!.prompt), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Letter generation timeout (60s)')), 80000), + ), + ]).catch((err: Error) => { + console.error('[Letter Error]:', err.message) + return null + }) if (resumes.length > 1) { // Выбор резюме @@ -288,7 +318,7 @@ export async function applyToJobs( console.log('Резюме не совпадает, нужно сменить') await currentResumeEl?.click() await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 }) - await page.pause() + // await page.pause() const options = await page.$$('label[role="option"]') for (const option of options) { const titleEl = await option.$('[data-qa="resume-title"] [data-qa="cell-text-content"]') @@ -300,7 +330,7 @@ export async function applyToJobs( } } } - await page.pause() + // await page.pause() const addLetter = await page.$('[data-qa="add-cover-letter"]') @@ -308,16 +338,14 @@ export async function applyToJobs( await addLetter?.hover() await addLetter?.click() } - const letter = await letterPromise - if (letter) { await keep(`✅ ${escapeHtml(vacancy.title)}\n\n${escapeHtml(letter)}`) const letterInput = await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') await letterInput?.click() await letterInput?.fill(letter) - await page.pause() + // await page.pause() } else { await keep(`Письмо не сгенерировано, ошибка`) @@ -339,23 +367,26 @@ export async function applyToJobs( } } else { - console.log('single flow') + console.log(`[Debug]: single flow: ${chatId}`) const letter = await letterPromise - console.log('letter: ', letter) if (letter) { await keep(`✅ ${escapeHtml(vacancy.title)}\n\n${escapeHtml(letter)}`) - const letterInput = await page.$('[data-qa="textarea-native-wrapper"] textarea') - ?? await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') + + await page.waitForSelector( + '[data-qa="textarea-wrapper"], [data-qa="vacancy-response-popup-form-letter-input"], [data-qa="textarea-native-wrapper"]', + { timeout: 10000 }, + ).catch(() => {}) + + const letterInput = await page.$('[data-qa="textarea-wrapper"] textarea') + ?? await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') ?? await page.$('[data-qa="textarea-native-wrapper"] textarea') await letterInput?.click() - await letterInput?.fill(letter) - await page.pause() + await letterInput?.fill(letter, { force: true }) } - await page.waitForTimeout(randomDelay()) - const submitBtn = await page.$('[data-qa="vacancy-response-letter-submit"]') ?? await page.$('[data-qa="vacancy-response-submit-popup"]')// vacancy-response-popup-submit + // await page.pause() if (submitBtn) { await submitBtn.click() await page.waitForTimeout(randomDelay()) diff --git a/src/index.ts b/src/index.ts index 7dbe4ad..f836423 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,11 @@ +// import * as process from 'node:process' import bot from '@bot' - import prisma from '@prisma' +import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js' process.on('unhandledRejection', (reason) => { - const msg = reason instanceof Error ? reason.message : String(reason) - console.error('[unhandledRejection]', msg) + console.error('[unhandledRejection]', reason) }) -import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js' registerHHCommands() diff --git a/src/openai.ts b/src/openai.ts index b9c70d0..c11d390 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -88,25 +88,53 @@ export async function createMessage(resume: string, message: string, prompt?: st const session = await client.session.create({ body: { title: 'Cover letter' } }) const sessionId = session.data!.id + console.log('sessionId: ', sessionId) + const finalPromt = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' - // Задаём роль без ответа + + const resumePreview = resume.slice(0, 200).replace(/\n/g, ' ') + console.log(`\n${'─'.repeat(60)}`) + console.log(`[LLM] 📋 Prompt 1 (system + resume, noReply)`) + console.log(`[LLM] system: ${finalPromt.slice(0, 80)}…`) + console.log(`[LLM] resume: ${resumePreview}…`) + console.log(`${'─'.repeat(60)}`) + await client.session.prompt({ path: { id: sessionId }, body: { noReply: true, - parts: [{ type: 'text', text: finalPromt }], + parts: [{ type: 'text', text: `${finalPromt}\n Резюме:\n${resume}` }], }, }) + + const vacancyPreview = message.slice(0, 300).replace(/\n/g, ' ') + console.log(`\n${'─'.repeat(60)}`) + console.log(`[LLM] 📝 Prompt 2 (vacancy) → ожидаю ответ…`) + console.log(`[LLM] vacancy: ${vacancyPreview}…`) + console.log(`${'─'.repeat(60)}`) + // ${prompt}\n\n const result = await client.session.prompt({ path: { id: sessionId }, body: { - parts: [{ type: 'text', text: `Резюме:\n${resume}\n\nВакансия:\n${message}` }], + parts: [{ type: 'text', text: `Вакансия:\n${message}` }], }, }) const parts = (result.data?.parts ?? []) as { type: string, text?: string }[] const textPart = parts.find(p => p.type === 'text') + console.log(`\n${'─'.repeat(60)}`) + console.log(`[LLM] ✅ Ответ получен (${textPart?.text?.length ?? 0} символов)`) + console.log(`[LLM] ${textPart?.text?.slice(0, 150).replace(/\n/g, ' ') ?? 'null'}…`) + console.log(`${'─'.repeat(60)}\n`) + + try { + await client.session.delete({ path: { id: sessionId } }) + } + catch (e) { + console.error('[Session cleanup error]:', (e as Error).message) + } + return textPart?.text ?? null }