From 6920e6d2bb5346888e5a22207f57dc5c273fd532 Mon Sep 17 00:00:00 2001 From: Oscar Date: Wed, 27 May 2026 17:15:35 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20refactor(file):=20=D0=98=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20HTML=20=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 1 + .../migration.sql | 2 + prisma/schema.prisma | 9 +- src/hh/bot-commands.ts | 9 +- src/hh/scraper.ts | 219 ++++++++++-------- 5 files changed, 139 insertions(+), 101 deletions(-) create mode 100644 prisma/migrations/20260527121131_resume_id_field/migration.sql diff --git a/eslint.config.mjs b/eslint.config.mjs index 1551981..aa29860 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,5 +3,6 @@ import antfu from '@antfu/eslint-config' export default antfu({ rules: { 'no-console': 0, + 'unicorn/prefer-dom-node-text-content': 0, }, }) diff --git a/prisma/migrations/20260527121131_resume_id_field/migration.sql b/prisma/migrations/20260527121131_resume_id_field/migration.sql new file mode 100644 index 0000000..10bbb4a --- /dev/null +++ b/prisma/migrations/20260527121131_resume_id_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Settings" ADD COLUMN "selectedResumeId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8401c7a..9e1dfcb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,8 +36,9 @@ model Resume { } model Settings { - telegramId BigInt @id - user User @relation(references: [telegramId], fields: [telegramId], onDelete: Cascade) - searchQuery String @default("Vue") - maxApplies Int @default(1) + telegramId BigInt @id + user User @relation(references: [telegramId], fields: [telegramId], onDelete: Cascade) + searchQuery String @default("Vue") + maxApplies Int @default(1) + selectedResumeId String? } diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index d93907e..57b3e6d 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -202,7 +202,7 @@ export function registerHHCommands() { if (result.errors.length) { lines.push('') lines.push('❌ Ошибки:') - result.errors.forEach(v => lines.push(`• ${v.title} — ${v.message}`)) + result.errors.forEach(v => lines.push(`• ${escapeHtml(v.title)} — ${escapeHtml(v.message ?? '')}`)) } const fullText = lines.join('\n') @@ -230,10 +230,9 @@ export function registerHHCommands() { } case 'hh_my_resume': { - const resume = await prisma.resume.findFirst({ - where: { telegramId: chatId }, - orderBy: { id: 'asc' }, - }) + const resume = settings?.selectedResumeId + ? await prisma.resume.findUnique({ where: { id: settings.selectedResumeId } }) + : await prisma.resume.findFirst({ where: { telegramId: chatId } }) if (!resume) { await showResult(chatId, messageId, '📋 Резюме не найдено.\n\nВыбери резюме через кнопку 📄 Выбрать резюме.') break diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index d5d7df8..4507290 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -5,6 +5,7 @@ import bot from '@bot' import prisma from '@prisma' import { createMessage } from '../openai' import { getBrowser, loadSession, randomDelay, randomScroll } from './browser.js' +import { escapeHtml } from './ui.js' function waitForOtp(chatId: number): Promise { return new Promise((resolve) => { @@ -97,21 +98,63 @@ export async function listResumes(chatId: number): Promise { try { await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-qa^="resume-card-link-"]', { timeout: 10000 }) - const resumes = await page.$$eval( - '[data-qa^="resume-card-link-"]', - links => links.map((a) => { - const card = a.closest('[data-qa^="resume-card"]') ?? a.parentElement - const titleEl = card?.querySelector('[data-qa="cell-text-content"]') - return { - href: (a as HTMLAnchorElement).getAttribute('href') ?? '', - title: titleEl?.textContent?.trim() ?? '(без названия)', - } - }), - ) + + 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.closest('[data-qa^="resume-card"]') ?? a.parentElement + const titleEl = card?.querySelector('[data-qa="resume-title"] h3') ?? card?.querySelector('[data-qa="title"]') + return { + href: (a as HTMLAnchorElement).getAttribute('href') ?? '', + title: titleEl?.textContent?.trim() ?? '(без названия)', + } + }), + ) + } + 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) { + console.log(`Failed to fetch resume text for ${item.title}:`, e) + } + } await browser.close() - console.log(resumes) - return resumes + console.log(items) + return items } catch (e) { lastError = e as Error @@ -124,39 +167,12 @@ export async function listResumes(chatId: number): Promise { throw lastError! } -export async function saveResume(chatId: number, resumeItem: ResumeListItem): Promise { - const browser = await getBrowser() - const context = await browser.newContext() - const page = await context.newPage() - await loadSession(page, chatId) - - const resumeHref = resumeItem.href - const title = resumeItem.title - - const id = new URL(`https://hh.ru${resumeHref}`).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' }) - - let resume: string | undefined - try { - resume = await page.locator('.resume').innerText() - await prisma.resume.deleteMany({ where: { telegramId: chatId, NOT: { id } } }) - await prisma.resume.upsert({ - where: { id }, - create: { data: resume, id, telegramId: chatId, title }, - update: { data: resume, title }, - }) - } - catch (e) { - console.log(e) - await bot.sendMessage(chatId, 'Нет резюме на hh.ru, создайте') - } - finally { - await browser.close() - } - - return resume +export async function saveResume(chatId: number, resumeItem: ResumeListItem): Promise { + const id = new URL(`https://hh.ru${resumeItem.href}`).pathname.split('/').pop()! + await prisma.settings.update({ + where: { telegramId: chatId }, + data: { selectedResumeId: id }, + }) } export async function applyToJobs( @@ -192,7 +208,9 @@ export async function applyToJobs( await status(`✅ Вакансий найдено: ${vacancies.length}`) - const resume = await prisma.resume.findFirst({ where: { telegramId: chatId } }) + const resumes = await prisma.resume.findMany({ where: { telegramId: chatId } }) + const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) + const resume = resumes.find(r => r.id === settings?.selectedResumeId) ?? resumes[0] const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) if (!resume?.data) { @@ -219,6 +237,7 @@ export async function applyToJobs( 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"]') @@ -232,60 +251,76 @@ export async function applyToJobs( await applyBtn.click() await page.waitForTimeout(randomDelay()) - // Выбор резюме - const currentResumeEl = await page.$('[data-qa="resume-title"]') - const currentResumeTitle = (await currentResumeEl?.innerText())?.trim() ?? '' - console.log('Текущее резюме на странице:', currentResumeTitle) - console.log('Ожидаемое резюме из БД:', resume.title) + if (resumes.length > 1) { + // Выбор резюме + const currentResumeEl = await page.$('[data-qa="resume-title"]') + const currentResumeTitle = (await currentResumeEl?.innerText())?.trim() ?? '' + console.log('Текущее резюме на странице:', currentResumeTitle) + console.log('Ожидаемое резюме из БД:', resume.title) - if (currentResumeTitle !== resume.title) { - console.log('Резюме не совпадает, нужно сменить') - await currentResumeEl?.click() - await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 }) - 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"]') - const title = (await titleEl?.innerText())?.trim() - if (title === resume.title) { - await option.click() - await page.waitForTimeout(randomDelay()) - break + if (currentResumeTitle !== resume.title) { + console.log('Резюме не совпадает, нужно сменить') + await currentResumeEl?.click() + await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 }) + 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"]') + const title = (await titleEl?.innerText())?.trim() + if (title === resume.title) { + await option.click() + await page.waitForTimeout(randomDelay()) + break + } } } - } - await page.pause() - - const addLetter = await page.$('[data-qa="add-cover-letter"]') - - if (addLetter) { - await addLetter?.hover() - await addLetter?.click() - } - - const letter = await letterPromise - await keep(`✅ ${vacancy.title}\n\n${letter}`) - - if (letter) { - const letterInput = await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') - - await letterInput?.click() - await letterInput?.fill(letter) await page.pause() - } - const submitBtn = await page.$('[data-qa="vacancy-response-submit-popup"]')// vacancy-response-popup-submit - if (submitBtn) { - await submitBtn.click() - await page.waitForTimeout(randomDelay()) + const addLetter = await page.$('[data-qa="add-cover-letter"]') + + if (addLetter) { + await addLetter?.hover() + await addLetter?.click() + } + + const letter = await letterPromise + await keep(`✅ ${escapeHtml(vacancy.title)}\n\n${escapeHtml(letter)}`) + + if (letter) { + const letterInput = await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') + + await letterInput?.click() + await letterInput?.fill(letter) + await page.pause() + } + + const submitBtn = await page.$('[data-qa="vacancy-response-submit-popup"]')// vacancy-response-popup-submit + if (submitBtn) { + await submitBtn.click() + await page.waitForTimeout(randomDelay()) + } + else { + const errMsg = 'Not found submit button' + console.log(errMsg) + results.errors.push({ ...ref, message: errMsg }) + } } else { - const errMsg = 'Not found submit button' - console.log(errMsg) - results.errors.push({ ...ref, message: errMsg }) + console.log('single flow') + + const letter = await letterPromise + console.log('letter: ', letter) + await keep(`✅ ${escapeHtml(vacancy.title)}\n\n${escapeHtml(letter)}`) + + if (letter) { + const letterInput = await page.$('[data-qa="textarea-native-wrapper"] textarea') + ?? await page.$('[data-qa="vacancy-response-popup-form-letter-input"]') + await letterInput?.click() + await letterInput?.fill(letter) + await page.pause() + } } - await page.pause() results.applied.push(ref) } catch (err) {