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?
|
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
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
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user