From c67fcfe4c69c182732398df13fae2401c73187c8 Mon Sep 17 00:00:00 2001 From: Oscar Date: Mon, 1 Jun 2026 11:03:33 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(resume):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B7=D1=8E=D0=BC?= =?UTF-8?q?=D0=B5=20=E2=AD=90=EF=B8=8F=20=D0=98=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20createLogger=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B0=D0=BC=D0=B8=20=D0=B2=20?= =?UTF-8?q?resume.ts.=20=D0=9B=D0=BE=D0=B3=D0=B8=20=D1=80=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B0=D1=89=D0=B0=D1=8E=D1=82=20=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B7=D1=8E=D0=BC=D0=B5?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot-singleton.ts | 3 +- src/global.d.ts | 7 ++++ src/globals.ts | 3 ++ src/hh/handlers/resume.ts | 4 +- src/hh/scraper.ts | 32 ++++++++------- src/index.ts | 7 +++- src/logger.ts | 83 +++++++++++++++++++++++++++++++++++++++ src/openai.ts | 33 +++++++--------- 8 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 src/global.d.ts create mode 100644 src/globals.ts create mode 100644 src/logger.ts diff --git a/src/bot-singleton.ts b/src/bot-singleton.ts index 841ed07..7327df7 100644 --- a/src/bot-singleton.ts +++ b/src/bot-singleton.ts @@ -1,6 +1,7 @@ import process from 'node:process' import TelegramBot from 'node-telegram-bot-api' +const log = createLogger('telegram') const token = process.env.TG_BOT_TOKEN! const bot = new TelegramBot(token, { polling: true }) @@ -8,7 +9,7 @@ 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) + log.error('polling_error', err?.code, err?.message) }) export default bot diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..e3f6a52 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,7 @@ +import type { Logger } from './logger.js' + +declare global { + function createLogger(tag: string): Logger +} + +export {} diff --git a/src/globals.ts b/src/globals.ts new file mode 100644 index 0000000..90afdb8 --- /dev/null +++ b/src/globals.ts @@ -0,0 +1,3 @@ +import { createLogger } from './logger.js' + +;(globalThis as any).createLogger = createLogger diff --git a/src/hh/handlers/resume.ts b/src/hh/handlers/resume.ts index 6c72153..e89980f 100644 --- a/src/hh/handlers/resume.ts +++ b/src/hh/handlers/resume.ts @@ -4,6 +4,8 @@ import { listResumes, NoResumeError, saveResume } from '../scraper.js' import { getState } from '../state.js' import { escapeHtml, NO_RESUME_MARKUP, safeEdit } from '../ui.js' +const log = createLogger('resume') + export async function handleResumeList(chatId: number): Promise { const state = getState(chatId) const loadingMsg = await bot.sendMessage(chatId, '🔄 Загружаю список резюме...') @@ -11,7 +13,7 @@ export async function handleResumeList(chatId: number): Promise { let resumes try { resumes = await listResumes(chatId) - console.log(`[handleResumeList ${chatId}]: ${resumes}`) + log.ok(`handleResumeList chatId=${chatId}:`, resumes) } catch (e) { await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {}) diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index 5cabf41..d11f6f4 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -8,6 +8,8 @@ import { createMessage } from '@/openai' import { loadSession, newStealthContext, randomDelay, randomScroll, withBrowser } from './browser.js' import { createStatusReporter, escapeHtml } from './ui.js' +const log = createLogger('scraper') + export class NoResumeError extends Error { constructor() { super('no_resume') @@ -51,7 +53,7 @@ async function skipIfQuestionnaire( return false const { keep } = createStatusReporter(chatId) await keep(`Пропущена вакансия: ${vacancy.title}`) - console.log(`[x] ${vacancy.title} hasQuestionnaire`) + log.warn(`[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 }, @@ -64,7 +66,7 @@ async function skipIfQuestionnaire( export async function login(email: string, chatId: number): Promise { await withBrowser(async (browser) => { if (!browser.version()) { - console.log('browser error') + log.error('browser error') return } @@ -157,7 +159,7 @@ export async function listResumes(chatId: number): Promise { }), ) - console.log(items.length) + log.info('resumes found:', items.length) } else { const href = await cardLinks[0].getAttribute('href') ?? '' @@ -188,11 +190,11 @@ export async function listResumes(chatId: number): Promise { }) } catch (e) { - console.log(`Failed to fetch resume text for ${item.title}:`, e) + log.error(`Failed to fetch resume text for ${item.title}:`, e) } } - console.log(items) + log.ok('listResumes:', items) return items } catch (e) { @@ -246,7 +248,7 @@ export async function applyToJobs( await loadSession(page, chatId) - const url = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&items_on_page=50&page=0` // &area=${area} + const url = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&items_on_page=100&page=0` // &area=${area} await page.goto(url, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-qa="serp-item__title"]', { timeout: 10000 }).catch(() => null) await page.pause() @@ -267,9 +269,9 @@ export async function applyToJobs( return Number(pageParam ?? 0) })), ) - console.log('[applyToJobs] Max page:', maxPage) + log.info('Max page:', maxPage) for (let p = 1; p <= maxPage; p++) { - const pageUrl = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&items_on_page=50&page=${p}` + const pageUrl = `https://hh.ru/search/vacancy?text=${encodeURIComponent(query)}&items_on_page=100&page=${p}` await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-qa="vacancy-serp__vacancy"]', { timeout: 10000 }).catch(() => null) const more = await collectPageVacancies(page) @@ -344,7 +346,7 @@ export async function applyToJobs( setTimeout(() => reject(new Error('Letter generation timeout (60s)')), 80000), ), ]).catch((err: Error) => { - console.error('[Letter Error]:', err.message) + log.error('Letter Error:', err.message) return null }) @@ -352,11 +354,11 @@ export async function applyToJobs( // Выбор резюме const currentResumeEl = await page.$('[data-qa="resume-title"]') const currentResumeTitle = (await currentResumeEl?.innerText())?.trim() ?? '' - console.log('Текущее резюме на странице:', currentResumeTitle) - console.log('Ожидаемое резюме из БД:', resume.title) + log.debug('Текущее резюме на странице:', currentResumeTitle) + log.debug('Ожидаемое резюме из БД:', resume.title) if (currentResumeTitle !== resume.title) { - console.log('Резюме не совпадает, нужно сменить') + log.warn('Резюме не совпадает, нужно сменить') await currentResumeEl?.click() await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 }) // await page.pause() @@ -401,14 +403,14 @@ export async function applyToJobs( } else { const errMsg = 'Not found submit button' - console.log(errMsg) + log.warn(errMsg) results.errors.push({ ...ref, message: errMsg }) // results.skipped.push(vacancy) continue } } else { - console.log(`[Debug]: single flow: ${chatId}`) + log.debug(`single flow: ${chatId}`) const letter = await letterPromise @@ -441,7 +443,7 @@ export async function applyToJobs( } else { const errMsg = 'Not found submit button' - console.log(errMsg) + log.warn(errMsg) results.errors.push({ ...ref, message: errMsg }) } } diff --git a/src/index.ts b/src/index.ts index 032d67a..ed01644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ // import * as process from 'node:process' +import './globals.js' import bot from '@bot' import prisma from '@prisma' import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js' +const log = createLogger('index') + process.on('unhandledRejection', (reason) => { - console.error('[unhandledRejection]', reason) + log.error('[unhandledRejection]', reason) }) // console.log('hi') //PWDEBUG=1 registerHHCommands() @@ -36,4 +39,4 @@ bot.onText(/\/start/, async (msg) => { await triggerHHStart(chatId) }) -console.log('Bot started 🚀') +log.ok('Bot started 🚀') diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..e2e99fb --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,83 @@ +const RESET = '\x1b[0m' +const BOLD = '\x1b[1m' +const DIM = '\x1b[2m' + +const colors = { + gray: '\x1b[90m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', + blue: '\x1b[34m', + white: '\x1b[37m', +} + +const LEVELS = { + info: { icon: '●', color: colors.cyan, label: 'INFO ' }, + success: { icon: '✔', color: colors.green, label: 'OK ' }, + warn: { icon: '▲', color: colors.yellow, label: 'WARN ' }, + error: { icon: '✖', color: colors.red, label: 'ERROR' }, + debug: { icon: '◆', color: colors.magenta, label: 'DEBUG' }, + llm: { icon: '◈', color: colors.blue, label: 'LLM ' }, +} as const + +type Level = keyof typeof LEVELS + +function timestamp(): string { + const now = new Date() + const hh = String(now.getHours()).padStart(2, '0') + const mm = String(now.getMinutes()).padStart(2, '0') + const ss = String(now.getSeconds()).padStart(2, '0') + const ms = String(now.getMilliseconds()).padStart(3, '0') + return `${DIM}${colors.gray}${hh}:${mm}:${ss}.${ms}${RESET}` +} + +function formatTag(tag: string): string { + return `${DIM}${colors.gray}[${tag}]${RESET}` +} + +function formatArgs(args: unknown[]): string { + return args + .map((a) => + typeof a === 'object' && a !== null + ? JSON.stringify(a, null, 2) + : String(a), + ) + .join(' ') +} + +function print(level: Level, tag: string, args: unknown[]): void { + const { icon, color, label } = LEVELS[level] + const parts = [ + timestamp(), + `${color}${BOLD}${icon} ${label}${RESET}`, + formatTag(tag), + `${color}${formatArgs(args)}${RESET}`, + ] + if (level === 'error') { + process.stderr.write(parts.join(' ') + '\n') + } else { + process.stdout.write(parts.join(' ') + '\n') + } +} + +export function createLogger(tag: string) { + return { + info: (...args: unknown[]) => print('info', tag, args), + ok: (...args: unknown[]) => print('success', tag, args), + warn: (...args: unknown[]) => print('warn', tag, args), + error: (...args: unknown[]) => print('error', tag, args), + debug: (...args: unknown[]) => print('debug', tag, args), + llm: (...args: unknown[]) => print('llm', tag, args), + divider: (label?: string) => { + const line = '─'.repeat(58) + const text = label + ? `${DIM}${colors.gray}┌─ ${label} ${'─'.repeat(Math.max(0, 54 - label.length))}${RESET}` + : `${DIM}${colors.gray}${line}${RESET}` + process.stdout.write(text + '\n') + }, + } +} + +export type Logger = ReturnType diff --git a/src/openai.ts b/src/openai.ts index e2e628e..cbce2b4 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -2,6 +2,7 @@ import process from 'node:process' import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk' // import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai' +const log = createLogger('llm') // export const claude = new Anthropic({ // apiKey: process.env.ANTHROPIC_API_KEY, @@ -55,12 +56,12 @@ export async function test() { }) const test = await client.config.providers() - console.log(test.data) + log.debug('providers', test.data) } export async function askLLM(userMessage: string) { const client = await getClient() - console.log('askLLM') + log.info('askLLM called') // Создаём сессию const session = await client.session.create({ body: { title: 'My request' }, @@ -85,20 +86,18 @@ export async function askLLM(userMessage: string) { export async function createMessage(resume: string, message: string, prompt?: string) { const client = await getClient() - console.log('[createMessage] client.instance: ', !!client.instance) + log.debug('client.instance:', !!client.instance) const session = await client.session.create({ body: { title: 'Cover letter' } }) const sessionId = session.data!.id - console.log('[createMessage] sessionId: ', sessionId) + log.debug('sessionId:', sessionId) const finalPromt = prompt || 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.' 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)}`) + log.divider('Prompt 1 — system + resume (noReply)') + log.llm(`system: ${finalPromt.slice(0, 80)}…`) + log.llm(`resume: ${resumePreview}…`) await client.session.prompt({ path: { id: sessionId }, @@ -109,10 +108,9 @@ export async function createMessage(resume: string, message: string, prompt?: st }) 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)}`) + log.divider('Prompt 2 — vacancy') + log.llm(`📝 vacancy → ожидаю ответ…`) + log.llm(`vacancy: ${vacancyPreview}…`) // ${prompt}\n\n const result = await client.session.prompt({ @@ -124,16 +122,15 @@ export async function createMessage(resume: string, message: string, prompt?: st 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`) + log.divider('Ответ получен') + log.llm(`✅ ${textPart?.text?.length ?? 0} символов`) + log.llm(`${textPart?.text?.slice(0, 150).replace(/\n/g, ' ') ?? 'null'}…`) try { await client.session.delete({ path: { id: sessionId } }) } catch (e) { - console.error('[Session cleanup error]:', (e as Error).message) + log.error('Session cleanup error:', (e as Error).message) } return textPart?.text ?? null