mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-06-08 18:04:57 +00:00
Compare commits
2 Commits
00d0a8d832
...
c173845910
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c173845910 | ||
|
|
a9de783891 |
@@ -41,12 +41,10 @@ jobs:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name hh-auto-reply \
|
--name hh-auto-reply \
|
||||||
--network traefik \
|
--network traefik \
|
||||||
--volume /home/koptilnya/services/chad/data:/app/data \
|
--volume /home/koptilnya/services/hh-auto-reply/data:/app/data \
|
||||||
-p 40000-40100:40000-40100/udp \
|
--env OPENROUTER_API_KEY=${{secrets.OPENROUTER_API_KEY}}
|
||||||
--label "traefik.enable=true" \
|
--env DATABASE_URL=${{secrets.DATABASE_URL}}
|
||||||
--label "traefik.http.routers.hh-auto-reply.rule=Host(\`api.koptilnya.xyz\`) && PathPrefix(\`/hh-auto-reply\`)" \
|
--env YOUR_TELEGRAM_ID=${{secrets.YOUR_TELEGRAM_ID}}
|
||||||
--label "traefik.http.routers.hh-auto-reply.entrypoints=websecure" \
|
--env GROQ_API_KEY=${{secrets.GROQ_API_KEY}}
|
||||||
--label "traefik.http.routers.hh-auto-reply.tls=true" \
|
--env TG_BOT_TOKEN=${{secrets.TG_BOT_TOKEN}}
|
||||||
--label "traefik.http.routers.hh-auto-reply.tls.certresolver=myresolver" \
|
|
||||||
--label "traefik.http.services.hh-auto-reply.loadbalancer.server.port=80" \
|
|
||||||
hh-auto-reply:latest
|
hh-auto-reply:latest
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ import TelegramBot from 'node-telegram-bot-api'
|
|||||||
const token = process.env.TG_BOT_TOKEN!
|
const token = process.env.TG_BOT_TOKEN!
|
||||||
const bot = new TelegramBot(token, { polling: true })
|
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
|
export default bot
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Message } from 'node-telegram-bot-api'
|
import type { Message } from 'node-telegram-bot-api'
|
||||||
|
import type { Page } from 'playwright'
|
||||||
import type { ApplyOptions, ApplyResult, ResumeListItem, VacancyRef } from './types.js'
|
import type { ApplyOptions, ApplyResult, ResumeListItem, VacancyRef } from './types.js'
|
||||||
import type { StatusReporter } from './ui.js'
|
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, randomDelay, randomScroll } from './browser.js'
|
import { getBrowser, loadSession, randomDelay, randomScroll } from './browser.js'
|
||||||
import { escapeHtml } from './ui.js'
|
import { escapeHtml } from './ui.js'
|
||||||
|
|
||||||
@@ -19,6 +20,39 @@ function waitForOtp(chatId: number): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void>,
|
||||||
|
results: ApplyResult,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<void> {
|
export async function login(email: string, chatId: number): Promise<void> {
|
||||||
const browser = await getBrowser()
|
const browser = await getBrowser()
|
||||||
// await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`)
|
// await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`)
|
||||||
@@ -248,11 +282,6 @@ export async function applyToJobs(
|
|||||||
continue
|
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"]')
|
const applyBtn = await page.$('[data-qa="vacancy-response-link-top"]')
|
||||||
if (!applyBtn) {
|
if (!applyBtn) {
|
||||||
results.skipped.push(vacancy)
|
results.skipped.push(vacancy)
|
||||||
@@ -262,20 +291,21 @@ export async function applyToJobs(
|
|||||||
await randomScroll(page)
|
await randomScroll(page)
|
||||||
|
|
||||||
await applyBtn.click()
|
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 (await skipIfQuestionnaire(page, vacancy, ref, chatId, status, results))
|
||||||
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)
|
|
||||||
continue
|
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<null>((_, 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) {
|
if (resumes.length > 1) {
|
||||||
// Выбор резюме
|
// Выбор резюме
|
||||||
@@ -288,7 +318,7 @@ export async function applyToJobs(
|
|||||||
console.log('Резюме не совпадает, нужно сменить')
|
console.log('Резюме не совпадает, нужно сменить')
|
||||||
await currentResumeEl?.click()
|
await currentResumeEl?.click()
|
||||||
await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 })
|
await page.waitForSelector('[data-qa="magritte-select-option-list"]', { timeout: 5000 })
|
||||||
await page.pause()
|
// await page.pause()
|
||||||
const options = await page.$$('label[role="option"]')
|
const options = await page.$$('label[role="option"]')
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
const titleEl = await option.$('[data-qa="resume-title"] [data-qa="cell-text-content"]')
|
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"]')
|
const addLetter = await page.$('[data-qa="add-cover-letter"]')
|
||||||
|
|
||||||
@@ -308,16 +338,14 @@ export async function applyToJobs(
|
|||||||
await addLetter?.hover()
|
await addLetter?.hover()
|
||||||
await addLetter?.click()
|
await addLetter?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const letter = await letterPromise
|
const letter = await letterPromise
|
||||||
|
|
||||||
if (letter) {
|
if (letter) {
|
||||||
await keep(`✅ <b>${escapeHtml(vacancy.title)}</b>\n\n${escapeHtml(letter)}`)
|
await keep(`✅ <b>${escapeHtml(vacancy.title)}</b>\n\n${escapeHtml(letter)}`)
|
||||||
const letterInput = await page.$('[data-qa="vacancy-response-popup-form-letter-input"]')
|
const letterInput = await page.$('[data-qa="vacancy-response-popup-form-letter-input"]')
|
||||||
|
|
||||||
await letterInput?.click()
|
await letterInput?.click()
|
||||||
await letterInput?.fill(letter)
|
await letterInput?.fill(letter)
|
||||||
await page.pause()
|
// await page.pause()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await keep(`Письмо не сгенерировано, ошибка`)
|
await keep(`Письмо не сгенерировано, ошибка`)
|
||||||
@@ -339,23 +367,32 @@ export async function applyToJobs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.log('single flow')
|
console.log(`[Debug]: single flow: ${chatId}`)
|
||||||
|
|
||||||
const letter = await letterPromise
|
const letter = await letterPromise
|
||||||
console.log('letter: ', letter)
|
|
||||||
|
|
||||||
if (letter) {
|
if (letter) {
|
||||||
await keep(`✅ <b>${escapeHtml(vacancy.title)}</b>\n\n${escapeHtml(letter)}`)
|
await keep(`✅ <b>${escapeHtml(vacancy.title)}</b>\n\n${escapeHtml(letter)}`)
|
||||||
const letterInput = await page.$('[data-qa="textarea-native-wrapper"] textarea')
|
|
||||||
?? await page.$('[data-qa="vacancy-response-popup-form-letter-input"]')
|
const LETTER_SELECTORS = [
|
||||||
|
'[data-qa="textarea-wrapper"] textarea',
|
||||||
|
'[data-qa="vacancy-response-popup-form-letter-input"]',
|
||||||
|
'[data-qa="textarea-native-wrapper"] textarea',
|
||||||
|
]
|
||||||
|
|
||||||
|
await page.waitForSelector(LETTER_SELECTORS.join(', '), { timeout: 10000 }).catch(() => {})
|
||||||
|
|
||||||
|
let letterInput = null
|
||||||
|
for (const sel of LETTER_SELECTORS) {
|
||||||
|
letterInput = await page.$(sel)
|
||||||
|
if (letterInput) break
|
||||||
|
}
|
||||||
await letterInput?.click()
|
await letterInput?.click()
|
||||||
await letterInput?.fill(letter)
|
await letterInput?.fill(letter, { force: true })
|
||||||
await page.pause()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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) {
|
if (submitBtn) {
|
||||||
await submitBtn.click()
|
await submitBtn.click()
|
||||||
await page.waitForTimeout(randomDelay())
|
await page.waitForTimeout(randomDelay())
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
// import * as process from 'node:process'
|
||||||
import bot from '@bot'
|
import bot from '@bot'
|
||||||
|
|
||||||
import prisma from '@prisma'
|
import prisma from '@prisma'
|
||||||
|
import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js'
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
const msg = reason instanceof Error ? reason.message : String(reason)
|
console.error('[unhandledRejection]', reason)
|
||||||
console.error('[unhandledRejection]', msg)
|
|
||||||
})
|
})
|
||||||
import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js'
|
|
||||||
|
|
||||||
registerHHCommands()
|
registerHHCommands()
|
||||||
|
|
||||||
|
|||||||
@@ -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 session = await client.session.create({ body: { title: 'Cover letter' } })
|
||||||
const sessionId = session.data!.id
|
const sessionId = session.data!.id
|
||||||
|
|
||||||
|
console.log('sessionId: ', sessionId)
|
||||||
|
|
||||||
const finalPromt = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
|
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({
|
await client.session.prompt({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
body: {
|
body: {
|
||||||
noReply: true,
|
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
|
// ${prompt}\n\n
|
||||||
const result = await client.session.prompt({
|
const result = await client.session.prompt({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
body: {
|
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 parts = (result.data?.parts ?? []) as { type: string, text?: string }[]
|
||||||
const textPart = parts.find(p => p.type === 'text')
|
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
|
return textPart?.text ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user