Compare commits

...

41 Commits

Author SHA1 Message Date
Oscar
162abdfc0a 🔧 upd(README.md)
All checks were successful
Deploy / deploy (push) Successful in 29s
2026-06-05 11:33:41 +03:00
Oscar
33f67279c1 ⚠️ fix(scraper): останавливает выполнение при 10 подряд пропущенных вакансий
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-06-01 13:01:42 +03:00
Oscar
0378b57a9d 🎨 refactor(logger): улучшаем форматирование и цвета в логгере для лучшей читаемости и консистентности.
All checks were successful
Deploy / deploy (push) Successful in 53s
🔧 fix(scraper): добавляем логирование URL и максимальной страницы для улучшения отладки.
2026-06-01 12:29:34 +03:00
Oscar
4238058583 refactor(scraper.ts): упрощает обработку OTP с помощью функции handleOtpFlow для повышения читаемости кода
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-06-01 12:11:07 +03:00
Oscar
79ae47de71 update
All checks were successful
Deploy / deploy (push) Successful in 3m2s
2026-06-01 12:00:41 +03:00
Oscar
199e58b251 🔧 fix(handler): Добавлен вызов функции createLogger для работы с логгером.
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-06-01 11:36:11 +03:00
Oscar
a52d7a1706 🌟 feat(file/topic): Добавление обработчиков входа по email и телефону.
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-06-01 11:29:41 +03:00
Oscar
ed87b2c642 🚀 feat(bot-commands): Добавлена функция отладки для региона.
All checks were successful
Deploy / deploy (push) Successful in 49s
2026-06-01 11:22:51 +03:00
Oscar
d883b17bd1 🚀 feature(file): Реализована функциональность "onboarding" для нового пользователя.
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-06-01 11:18:18 +03:00
Oscar
0c18846cbb 🔧 fix(file): Изменено использование DEFAULT_PROMPT в handlePrompt()
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-06-01 11:08:04 +03:00
Oscar
024b8f41b8 🔧 Fix(logger): Изменена функция timestamp для отображения дней и месяцев.
All checks were successful
Deploy / deploy (push) Successful in 47s
2026-06-01 11:04:57 +03:00
Oscar
c67fcfe4c6 feat(resume): добавлены логи для обработчика резюме
All checks were successful
Deploy / deploy (push) Successful in 48s
️ Использован createLogger для управления логами в resume.ts. Логи рапперащают о загрузке списка резюме и ошибках.
2026-06-01 11:03:33 +03:00
Oscar
9434eeebfe feat(file): Добавлена функция collectPageVacancies для сбора информации о вакансиях на странице.
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-06-01 10:57:07 +03:00
Oscar
c10f8282fc 🌟 feat(file): Добавлены кнопки в UI для работы с фильтрами.
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-05-29 17:53:06 +03:00
Oscar
aded3fff7e 🚀 feat(bot-commands): Добавлена обработка статуса "откликаюсь на вакансии" при команде /apply.
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-29 17:07:35 +03:00
Oscar
6acc8d0adb feat(file/topic): добавлено меню в главном окне бота. UI обновлен в соответствии с новыми требованиями.
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-29 12:51:04 +03:00
Oscar
810952a5c7 🔧 fix(bot-commands): Исправлены асинхронные операции удаления сообщений
All checks were successful
Deploy / deploy (push) Successful in 55s
Исправлены проблемы с удалением сообщений и обновлением состояний.
2026-05-29 11:50:54 +03:00
Oscar
7b8d6350b8 🌟 feat(file): обновлены запросы к базе данных для обновления/создания пользователя.
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-29 00:45:31 +03:00
Oscar
956551e30e 🚀 feat(file): добавлено свойство awaitingPrompt для ожидания ввода промта.
All checks were successful
Deploy / deploy (push) Successful in 3m21s
2026-05-28 23:45:24 +03:00
Oscar
124302c661 👩‍💻 refactor(bot-commands): Обновлены кнопки в UI для настроек и информации.
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-28 22:47:11 +03:00
Oscar
675a14bba0 🔧 fix(workflows/deploy.yml): Исправлен путь к данным в docker-контейнере.
All checks were successful
Deploy / deploy (push) Successful in 23s
2026-05-28 22:40:59 +03:00
Oscar
eb898f5604 feat(file): Добавлены кнопки для управления действиями чат-бота на hh.ru. Добавлено меню для оформления запроса, отклика и других действий.
All checks were successful
Deploy / deploy (push) Successful in 48s
2026-05-28 22:27:32 +03:00
Oscar
e50eb58799 🔧 fix(bot-commands): Исправлен импорт и добавлены обработчики исключений в listResumes.
All checks were successful
Deploy / deploy (push) Successful in 50s
2026-05-28 21:47:30 +03:00
Oscar
43cc28f1cf 🔧 fix(browser): Изменен размер viewport на 1920x1080, добавлен вывод sessionId в createMessage.
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-28 21:26:06 +03:00
Oscar
dffb59b79e 🔧 fix(browser): Изменен размер viewport на 1920x1080, добавлен вывод sessionId в createMessage.
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-05-28 21:25:46 +03:00
Oscar
a9635be640 🎉 feat(browser): Добавлена функция создания нового скрытого контекста для браузера.
All checks were successful
Deploy / deploy (push) Successful in 47s
2026-05-28 21:21:23 +03:00
Oscar
6ae9b32e87 📦 refactor(Dockerfile): Удалены повторяющиеся установки зависимостей, улучшена производительностьрыность.
All checks were successful
Deploy / deploy (push) Successful in 2m33s
2026-05-28 15:48:09 +03:00
Oscar
7fd71f27c8 🔧 refactor(Dockerfile): Удалены лишние установки зависимостей, оптимизирована установка Playwright.tabs
Some checks failed
Deploy / deploy (push) Failing after 31s
2026-05-28 15:44:25 +03:00
Oscar
a2b3539f35 🔨 refactor(browser/scraper): Код оптимизирован и обработка ошибок улучшена
All checks were successful
Deploy / deploy (push) Successful in 46s
2026-05-28 15:30:32 +03:00
Oscar
1273eff7b7 feat(Dockerfile): Обновлены зависимости и пакеты для установки Playwright.
All checks were successful
Deploy / deploy (push) Successful in 1m55s
2026-05-28 14:28:36 +03:00
Oscar
ce07d962d7 🚀 feat(prisma/schema.prisma): Обновлен провайдер и добавлены целевые бинарные файлы
All checks were successful
Deploy / deploy (push) Successful in 1m23s
🔨 fix(Dockerfile): Добавлены установка OpenSSL и удаление неиспользуемых пакетов
2026-05-28 14:22:09 +03:00
Oscar
01d014ff54 🚀 feat(Dockerfile): Устанавливаю Chromium и системные зависимости для Playwright.
All checks were successful
Deploy / deploy (push) Successful in 3m31s
2026-05-28 14:14:22 +03:00
Oscar
16924d5f6a 🔨 refactor(browser/launch): обновлены настройки запуска браузера для отладки.
All checks were successful
Deploy / deploy (push) Successful in 3m26s
2026-05-28 13:56:41 +03:00
Oscar
0c9f52a349 🔧 FIX(workflow): Удаление переменной среды YOUR_TELEGRAM_ID для безопасности.
All checks were successful
Deploy / deploy (push) Successful in 23s
2026-05-28 13:50:23 +03:00
Oscar
49d711d29b 🚀 Изменение(index): Добавлена консольная печать в index.ts.
All checks were successful
Deploy / deploy (push) Successful in 3m33s
2026-05-28 13:24:10 +03:00
Oscar
6b2b2589ac 🚀 Изменение(index): Добавлена консольная печать в index.ts.
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-05-28 13:03:47 +03:00
Oscar
66733c0f75 🛠️ FIX(workflow): Обновлена конфигурация деплоя для Docker в файле deploy.yml.
All checks were successful
Deploy / deploy (push) Successful in 20s
2026-05-28 13:00:04 +03:00
Oscar
2407b166b5 🛠️ fix(deploy.yml): добавлена точка в команду docker build для корректной сборки контейнера.
Some checks failed
Deploy / deploy (push) Failing after 5m59s
2026-05-28 12:51:12 +03:00
Oscar
604c77a04d 🐛 fix(scraper): исправлены селекторы для ввода текста в письмо
Some checks failed
Deploy / deploy (push) Failing after 9s
🚀 feat(deploy): добавлены переменные окружения для деплоя
В сообщении обязательно два разных типа, например, fix и feat.
2026-05-28 12:49:38 +03:00
Oscar
c173845910 🐛 fix(scraper): исправлены селекторы для ввода текста в письмо
Some checks failed
Deploy / deploy (push) Failing after 18s
🚀 feat(deploy): добавлены переменные окружения для деплоя
В сообщении обязательно два разных типа, например, fix и feat.
2026-05-28 12:47:38 +03:00
Oscar
a9de783891 feat(file): Добавлена функция skipIfQuestionnaire для пропуска анкетирования вакансии. 2026-05-28 12:08:48 +03:00
25 changed files with 1758 additions and 766 deletions

View File

@@ -14,12 +14,6 @@ jobs:
run: |
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
- name: Set up secret file
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" | sed 's/./& /g'
- name: Checkout
uses: actions/checkout@v4
with:
@@ -31,22 +25,22 @@ jobs:
# run: cp /home/koptilnya/services/chad/data/database.db /home/koptilnya/services/chad/database-$(date+"%d-%m-%Y").db
- name: Build
run: docker build -t hh-auto-reply ./server
run: docker build -t hh-auto-reply .
- name: Stop old container
run: docker rm -f hh-auto-reply || true
#docker run --rm --env-file .env.docker -v $(pwd)/data:/data oscr-test-bot
- name: Run
run: |
docker run -d \
--name hh-auto-reply \
--network traefik \
--volume /home/koptilnya/services/chad/data:/app/data \
-p 40000-40100:40000-40100/udp \
--label "traefik.enable=true" \
--label "traefik.http.routers.hh-auto-reply.rule=Host(\`api.koptilnya.xyz\`) && PathPrefix(\`/hh-auto-reply\`)" \
--label "traefik.http.routers.hh-auto-reply.entrypoints=websecure" \
--label "traefik.http.routers.hh-auto-reply.tls=true" \
--label "traefik.http.routers.hh-auto-reply.tls.certresolver=myresolver" \
--label "traefik.http.services.hh-auto-reply.loadbalancer.server.port=80" \
--volume /home/koptilnya/services/hh-auto-reply/data:/data \
--env OPENROUTER_API_KEY=${{secrets.OPENROUTER_API_KEY}} \
--env DATABASE_URL=${{secrets.DATABASE_URL}} \
--env GROQ_API_KEY=${{secrets.GROQ_API_KEY}} \
--env TG_BOT_TOKEN=${{secrets.TG_BOT_TOKEN}} \
hh-auto-reply:latest

View File

@@ -3,7 +3,6 @@ FROM node:22-slim AS builder
WORKDIR /app
# build tools for native modules
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
@@ -13,6 +12,9 @@ RUN corepack enable
RUN yarn install --immutable
# Download Chromium into /root/.cache/ms-playwright (with deps for builder OS)
RUN npx playwright install --with-deps chromium
COPY tsconfig.json ./
COPY prisma ./prisma
COPY src ./src
@@ -23,22 +25,24 @@ RUN yarn prisma generate
FROM node:22-slim AS runner
WORKDIR /app
RUN corepack enable
RUN apt-get update && apt-get install -y --no-install-recommends openssl && rm -rf /var/lib/apt/lists/*
RUN corepack enable && npm install -g opencode-ai
COPY --from=builder /app/node_modules ./node_modules
# Copy pre-downloaded browser binary, then install only system deps (no re-download)
COPY --from=builder /root/.cache/ms-playwright /root/.cache/ms-playwright
RUN npx playwright install-deps chromium
COPY --from=builder /app/src ./src
COPY --from=builder /app/prisma ./prisma
COPY package.json yarn.lock .yarnrc.yml tsconfig.json ./
# install Chromium + all system dependencies for Playwright
RUN npx playwright install --with-deps chromium
# SQLite data lives on a volume so it survives container restarts
RUN mkdir -p /data
ENV DATABASE_URL="file:/data/dev.db"
ENV NODE_ENV=production
VOLUME ["/data"]
# apply pending migrations then start the bot
CMD ["sh", "-c", "npx prisma migrate deploy && npx tsx src/index.ts"]

345
README.md
View File

@@ -1,144 +1,251 @@
# 🤖 HH Auto-Apply Bot
# HH Auto-Apply Bot
Telegram-бот для автоматизации откликов на вакансии с hh.ru. Бот логинится на hh.ru от твоего имени, парсит вакансии по запросу и с помощью LLM генерирует персонализированное сопроводительное письмо под каждую вакансию — с учётом твоего резюме и описания позиции.
Telegram-бот для автоматизации откликов на вакансии с **hh.ru**. Авторизуется на сайте от имени пользователя, парсит вакансии по заданному запросу и генерирует персонализированное сопроводительное письмо под каждую позицию — с учётом резюме и описания вакансии.
> **Статус:** активная разработка. Генерация писем работает; финальный клик «Откликнуться» реализован, логика отклика отлажена на реальных вакансиях.
---
## Что умеет бот
- **Авторизация через OTP** — входит на hh.ru по email или номеру телефона + одноразовый код, сессия (cookies) сохраняется в БД и переиспользуется
- **Парсинг резюме** — скачивает резюме с hh.ru после логина, сохраняет текстом в БД
- **Поиск вакансий** — по настраиваемому запросу, с лимитом откликов за сессию
- **AI-письма** — уникальное сопроводительное письмо под каждую вакансию через LLM (DeepSeek V4 Flash / OpenRouter)
- **Пропуск анкет** — вакансии с опросником автоматически пропускаются и логируются
- **Авто-режим** — cron-задача (пн–пт, 10:00) для ежедневного автозапуска
- **Полное управление через Telegram** — настройки, промпт, резюме, статус — всё в чате
---
## Стек
| Слой | Технология |
|---|---|
| Runtime | Node.js 20+ (ESM), TypeScript |
| Runtime | Node.js 22+ · TypeScript · ESM |
| Telegram | node-telegram-bot-api (long polling) |
| Браузерная автоматизация | Playwright (headless Chromium) |
| LLM / AI | Groq API — `llama-3.3-70b-versatile` |
| ORM | Prisma 6 + SQLite |
| LLM | DeepSeek V4 Flash · OpenRouter · `@opencode-ai/sdk`; Groq (`llama-3.3-70b-versatile`) как запасной |
| ORM | Prisma 6 · SQLite |
| Планировщик | node-cron |
| Пакетный менеджер | Yarn 4 |
| Контейнеризация | Docker |
| Пакетный менеджер | Yarn 4 (PnP off) |
---
## Как это работает
```
Пользователь Telegram Bot
├─ Авторизация на hh.ru (Playwright + OTP через бот)
│ └─ Сохранение сессии (cookies) в БД
├─ Парсинг резюме с hh.ru → сохранение в БД
└─ Поиск вакансий по запросу
└─ Для каждой вакансии:
├─ Playwright парсит описание
└─ LLM (Llama 3.3 70B) генерирует
сопроводительное письмо на основе
резюме + описания вакансии
Пользователь в Telegram
├─ /start → главное меню
├─ Войти на hh.ru
└─ Playwright открывает браузер, вводит email или телефон
└─ OTP-код пользователь присылает в чат
└─ Сессия (cookies) сохраняется в БД
├─ Выбрать резюме → Playwright парсит список резюме с hh.ru
└─ Откликнуться
└─ Playwright ищет вакансии по запросу
└─ Для каждой вакансии:
├─ Парсит описание (название, требования, компания)
├─ OpenCode SDK → DeepSeek V4 Flash генерирует письмо (резюме + описание → письмо)
├─ Playwright вставляет письмо и кликает «Откликнуться»
└─ Отчёт в Telegram: применено / пропущено / ошибки
```
## AI / LLM
---
Сопроводительные письма генерирует **Llama 3.3 70B** через Groq API (OpenAI-совместимый интерфейс). Модель получает:
## AI: как генерируются письма
- Системный промпт с инструкциями по стилю (настраивается пользователем)
- Текст резюме пользователя (парсится с hh.ru)
- Описание конкретной вакансии (парсится Playwright'ом в реальном времени)
Используется **DeepSeek V4 Flash** через [OpenRouter](https://openrouter.ai). Интеграция реализована через `@opencode-ai/sdk` — при первом запросе бот поднимает локальный OpenCode-сервер (`localhost:4096`), при последующих переиспользует его.
На выходе — живое, не шаблонное письмо, адаптированное под конкретную позицию.
Каждая сессия генерации состоит из двух шагов:
В коде также подключён **Anthropic SDK** (`@anthropic-ai/sdk`) для возможности использования Claude.
1. **Первый промпт (без ответа)** — системная инструкция + полный текст резюме
2. **Второй промпт** — описание вакансии, модель возвращает готовое письмо
## Возможности
Пользователь настраивает системный промпт прямо в боте через кнопку `📝 Промт`. По умолчанию модель пишет коротко, опирается только на факты из резюме и добавляет контакты в конце.
- **Авторизация** — вход на hh.ru через email + OTP-код, сессия сохраняется в БД и переиспользуется
- **Парсинг резюме** — автоматически скачивает твоё резюме с hh.ru после логина
- **Поиск вакансий** — поиск по настраиваемому запросу с лимитом откликов
- **AI-сопроводительные письма** — уникальное письмо под каждую вакансию через LLM
- **Авто-режим** — крон-задача (пн–пт, 10:00) для ежедневного автозапуска
- **Настройки через бот** — поисковый запрос, макс. кол-во откликов — всё меняется прямо в чате
## Команды бота
| Кнопка / команда | Действие |
|---|---|
| `/start` | Регистрация, главное меню |
| `💼 Меню` | Панель управления HH Auto-Apply |
| `🚀 Откликнуться сейчас` | Запустить поиск и генерацию писем |
| `🔍 Изменить запрос` | Сменить поисковый запрос |
| `🔢 Макс откликов` | Установить лимит вакансий за сессию |
| `⏰ Авто вкл / Авто выкл` | Включить/выключить ежедневный крон |
| `🔑 Логин` | Авторизоваться на hh.ru |
| `⚙️ Статус` | Текущие настройки и статус авторизации |
## База данных
```
User — telegramId, hhEmail, session (cookies), AI-промпт
Resume — текст резюме, привязан к пользователю
Settings — searchQuery, maxApplies
```
## Docker
### Сборка образа
```bash
docker build -t oscr-test-bot .
```
### Запуск контейнера
Создай файл `.env.docker`:
```
DATABASE_URL=file:/data/dev.db
TG_BOT_TOKEN=your_token
GROQ_API_KEY=your_key
OPENROUTER_API_KEY=your_key
```
Запуск (Linux/Mac):
```bash
docker run --rm --env-file .env.docker -v $(pwd)/data:/data oscr-test-bot
```
Запуск (Windows PowerShell):
```powershell
docker run --rm --env-file .env.docker -v //c/MyApps/oscr-test-bot/data:/data oscr-test-bot
```
SQLite база данных сохраняется в папку `data/` и переживает перезапуски контейнера.
## Запуск локально
```bash
# 1. Установить зависимости
yarn
# 2. Создать .env
echo 'DATABASE_URL="file:./dev.db"' > .env
# 3. Применить миграции
yarn db-migrate
# 4. Запустить в dev-режиме
yarn dev
```
## Команды разработки
```bash
yarn dev # запуск с hot reload (tsx watch)
yarn build # компиляция TypeScript → dist/
yarn start # запуск скомпилированного кода
yarn lint # проверка ESLint
yarn lint:fix # автофикс ESLint
yarn db-view # Prisma Studio (GUI для БД)
yarn db-migrate # создать и применить миграцию
yarn db-deploy # применить миграции на проде
```
---
## Архитектура
Одиночный Node.js процесс без HTTP-сервера. Весь UI — Telegram inline-клавиатуры и сообщения. Два синглтона (`TelegramBot`, `PrismaClient`) шарятся через path-алиасы `@bot` и `@prisma`.
Одиночный Node.js процесс без HTTP-сервера. Весь UI — Telegram reply-клавиатуры и inline-кнопки.
Playwright запускается headless для каждой операции с hh.ru и закрывается после — никаких persistent browser-процессов. Для имитации человеческого поведения используются случайные задержки и скроллы.
```
src/
├── index.ts # точка входа, регистрация хендлеров
├── bot-singleton.ts # singleton TelegramBot (@bot)
├── prisma.ts # singleton PrismaClient (@prisma)
├── openai.ts # LLM: OpenCode SDK (DeepSeek V4 Flash/OpenRouter) + Groq fallback
└── hh/
├── bot-commands.ts # маршрутизация: handler maps вместо switch
├── state.ts # UserState (awaiting-флаги, cron, pending resumes)
├── scraper.ts # Playwright-автоматизация hh.ru
├── browser.ts # stealth-контекст, сессии, утилиты
├── ui.ts # клавиатуры, escapeHtml, StatusReporter
├── types.ts # общие типы
└── handlers/
├── apply.ts # запуск поиска и откликов
├── auth.ts # логин (email / телефон), OTP-флоу
├── info.ts # статус, проблемные вакансии
├── onboarding.ts # онбординг нового пользователя
├── resume.ts # список резюме, просмотр, выбор
├── settings.ts # запрос, лимит, промпт, авто-режим
└── debug.ts # отладочные функции
```
**Path-алиасы** (tsconfig.json): `@bot`, `@prisma`, `@/*``src/*`
Состояние диалога (`awaitingEmail`, `awaitingQuery` и т.д.) хранится in-memory в `Map<chatId, UserState>` — сбрасывается при рестарте процесса. Персистентные данные (сессия, резюме, настройки) — только в БД.
---
## База данных
```prisma
model User {
telegramId BigInt @unique
username String?
firstName String?
hhEmail String?
hhPhone String?
session String? // cookies hh.ru (JSON)
prompt String // системный промпт для AI
resumes Resume[]
Settings Settings?
}
model Resume {
id String @id // хэш от URL резюме на hh.ru
title String
data String // полный текст резюме
telegramId BigInt
}
model Settings {
telegramId BigInt @id
searchQuery String @default("Vue")
maxApplies Int @default(1)
selectedResumeId String?
}
model SkippedVacancy {
telegramId BigInt
href String
title String
createdAt DateTime
@@unique([telegramId, href])
}
```
---
## Запуск локально
### 1. Зависимости
```bash
yarn
```
### 2. Переменные окружения
Создай `.env` в корне проекта:
```env
# База данных (обязательно)
DATABASE_URL="file:./prisma/dev.db"
# Telegram Bot Token — получить у @BotFather
TG_BOT_TOKEN=1234567890:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OpenRouter API Key — нужен для DeepSeek V4 Flash (openrouter.ai)
OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Groq API Key — используется функцией askGPT (запасной путь)
GROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 3. Миграции и запуск
```bash
yarn db-migrate # создать БД и применить миграции
yarn dev # запуск с hot reload
```
---
## Команды
```bash
yarn dev # запуск с hot reload (tsx watch + .env)
yarn build # компиляция TypeScript → dist/
yarn start # запуск скомпилированного dist/index.js
yarn lint # ESLint проверка
yarn lint:fix # ESLint автофикс
yarn db-view # Prisma Studio — GUI для БД
yarn db-migrate # создать и применить миграцию (dev)
yarn db:deploy # применить миграции без создания новых (prod)
```
---
## Docker
### Сборка
```bash
docker build -t hh-auto-apply-bot .
```
### Запуск
Создай `.env.docker`:
```env
DATABASE_URL=file:/data/dev.db
TG_BOT_TOKEN=...
OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
```bash
# Linux / macOS
docker run --rm --env-file .env.docker -v $(pwd)/data:/data hh-auto-apply-bot
# Windows PowerShell
docker run --rm --env-file .env.docker -v ${PWD}/data:/data hh-auto-apply-bot
```
SQLite-база сохраняется в `./data/` и переживает перезапуски контейнера.
---
## UI бота
**Главное меню**
| Кнопка | Действие |
|---|---|
| `🚀 Откликнуться` | Запустить поиск и отклики |
| `⚙️ Настройки` | Открыть меню настроек |
| ` Информация` | Открыть меню информации |
**Настройки**
| Кнопка | Действие |
|---|---|
| `🔢 Макс. откликов` | Лимит откликов за сессию (150) |
| `🔍 Изменить запрос` | Поисковый запрос на hh.ru |
| `⏰ Авто` | Вкл/выкл ежедневный cron (пн–пт 10:00) |
| `📄 Выбрать резюме` | Загрузить список резюме с hh.ru |
| `📝 Промт` | Настроить системный промпт для AI |
| `🔑 Войти на hh.ru` | Авторизация (email или телефон + OTP) |
**Информация**
| Кнопка | Действие |
|---|---|
| `⚙️ Статус` | Текущие настройки и статус авторизации |
| `📋 Моё резюме` | Просмотр сохранённого резюме |
| `🚫 Проблемные вакансии` | Вакансии с анкетой (бот не может откликнуться) |

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "hhPhone" TEXT;

View File

@@ -6,7 +6,8 @@
// output = "../src/generated/prisma"
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
datasource db {
@@ -22,8 +23,9 @@ model User {
createdAt DateTime @default(now())
session String?
hhEmail String?
hhPhone String?
resumes Resume[]
prompt String @default("Напиши сопроводительное письмо опираясь на резюме. Пиши грамотно и коротко, простым языком не официально. Пиши только текст самого письма, ты пишешь напрямую в компанию. Мне отвечать или делать ремарки не нужно.")
prompt String @default("Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.")
Settings Settings?
}

View File

@@ -1,8 +1,18 @@
import process from 'node:process'
import TelegramBot from 'node-telegram-bot-api'
import { createLogger } from './logger.js'
const log = createLogger('telegram')
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
log.error('polling_error', err?.code, err?.message)
})
export default bot

5
src/config.ts Normal file
View 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,
}

7
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import type { Logger } from './logger.js'
declare global {
function createLogger(tag: string): Logger
}
export {}

3
src/globals.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createLogger } from './logger.js'
;(globalThis as any).createLogger = createLogger

View File

@@ -1,156 +1,169 @@
import type { ResumeListItem } from './types.js'
import bot from '@bot'
import prisma from '@prisma'
import cron, { type ScheduledTask } from 'node-cron'
import { applyToJobs, checkIsAuth, listResumes, login, saveResume } from './scraper.js'
import { BACK_MARKUP, createStatusReporter, escapeHtml, LOGIN_MARKUP, MAIN_MARKUP, safeEdit, showResult } from './ui.js'
import { debugFunc } from '@/hh/handlers/debug'
import { handleApply } from './handlers/apply.js'
import { doLogin, doLoginByPhone, handleLogin, handleLoginByEmail, handleLoginByPhone } from './handlers/auth.js'
import { handleSkipped, handleStatus } from './handlers/info.js'
import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo } from './handlers/onboarding.js'
import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.js'
import { DEFAULT_PROMPT, handleAutoToggle, handleMax, handlePrompt, handleQuery } from './handlers/settings.js'
import { getState } from './state.js'
import { BTN, FILTERS_REPLY_KEYBOARD, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, SETTINGS_REPLY_KEYBOARD } from './ui.js'
interface UserState {
autoCron: ScheduledTask | null
awaitingEmail: boolean
awaitingQuery: boolean
awaitingMax: boolean
tempEmail: string
pendingResumes: ResumeListItem[]
menuMessageId: number | null
loginPromptMessageId: number | null
type MsgHandler = (chatId: number) => Promise<void>
type CallbackHandler = (chatId: number, messageId: number) => Promise<void>
const MESSAGE_HANDLERS: Partial<Record<string, MsgHandler>> = {
[BTN.APPLY]: handleApply,
[BTN.STATUS]: handleStatus,
[BTN.QUERY]: handleQuery,
[BTN.MAX]: handleMax,
[BTN.AUTO_TOGGLE]: handleAutoToggle,
[BTN.PROMPT]: handlePrompt,
[BTN.LOGIN]: handleLogin,
[BTN.RESUME_LIST]: handleResumeList,
[BTN.MY_RESUME]: handleMyResume,
[BTN.SKIPPED]: handleSkipped,
[BTN.SETTINGS]: async (chatId) => { await bot.sendMessage(chatId, '⚙️ Настройки:', { reply_markup: SETTINGS_REPLY_KEYBOARD }) },
[BTN.FILTERS]: async (chatId) => { await bot.sendMessage(chatId, '🔎 Фильтры:', { reply_markup: FILTERS_REPLY_KEYBOARD }) },
[BTN.INFO]: async (chatId) => { await bot.sendMessage(chatId, ' Информация:', { reply_markup: INFO_REPLY_KEYBOARD }) },
[BTN.BACK]: async (chatId) => { await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) },
[BTN.REGION]: debugFunc,
}
function makeUserState(): UserState {
return {
autoCron: null,
awaitingEmail: false,
awaitingQuery: false,
awaitingMax: false,
tempEmail: '',
pendingResumes: [],
menuMessageId: null,
loginPromptMessageId: null,
}
}
const states = new Map<number, UserState>()
function getState(chatId: number): UserState {
if (!states.has(chatId))
states.set(chatId, makeUserState())
return states.get(chatId)!
}
async function showMenu(chatId: number, messageId?: number | null): Promise<void> {
const state = getState(chatId)
const targetId = messageId ?? state.menuMessageId
if (targetId) {
try {
await safeEdit('🤖 HH Auto-Apply', {
chat_id: chatId,
message_id: targetId,
reply_markup: MAIN_MARKUP,
})
state.menuMessageId = targetId
return
}
catch {
// Сообщение устарело или недоступно — отправим новое
}
}
const msg = await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_MARKUP })
state.menuMessageId = msg.message_id
}
async function sendResumeSelector(chatId: number, resumes: ResumeListItem[], messageId: number): Promise<void> {
const state = getState(chatId)
state.pendingResumes = resumes
await safeEdit('📄 Выбери резюме:', {
chat_id: chatId,
message_id: messageId,
reply_markup: {
inline_keyboard: [
...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]),
[{ text: '◀️ Назад', callback_data: 'hh_back' }],
],
},
})
}
async function resetMenuToBottom(chatId: number): Promise<void> {
const state = getState(chatId)
if (state.menuMessageId) {
await bot.deleteMessage(chatId, state.menuMessageId).catch(() => {})
state.menuMessageId = null
}
}
async function doLogin(chatId: number, email: string): Promise<void> {
await bot.sendMessage(chatId, '🔄 Логинюсь...')
try {
await login(email, chatId)
await prisma.user.update({ where: { telegramId: chatId }, data: { hhEmail: email } })
const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
hh_back: async (chatId, messageId) => {
await bot.deleteMessage(chatId, messageId).catch(() => {})
},
hh_login: async (chatId, messageId) => {
await bot.deleteMessage(chatId, messageId).catch(() => {})
await handleLogin(chatId)
},
hh_login_method_email: async (chatId, messageId) => {
const state = getState(chatId)
// listResumes может упасть по таймауту сразу после логина — это не критично
let resumes: ResumeListItem[] | null = null
try {
resumes = await listResumes(chatId)
}
catch {
await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню')
}
await resetMenuToBottom(chatId)
if (resumes === null) {
// таймаут при загрузке резюме — просто показываем меню
}
else if (resumes.length === 0) {
await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru')
}
else if (resumes.length === 1) {
await saveResume(chatId, resumes[0])
await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`)
}
else {
// несколько резюме — отправляем новый селектор внизу
state.pendingResumes = resumes
const selectorMsg = await bot.sendMessage(chatId, '📄 Выбери резюме:', {
reply_markup: {
inline_keyboard: [
...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]),
[{ text: '◀️ Назад', callback_data: 'hh_back' }],
],
},
})
state.menuMessageId = selectorMsg.message_id
state.loginMethodMsgId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
await handleLoginByEmail(chatId)
},
hh_login_method_phone: async (chatId, messageId) => {
const state = getState(chatId)
state.loginMethodMsgId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
await handleLoginByPhone(chatId)
},
hh_login_use_current: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingEmail = false
state.loginPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
if (!user?.hhEmail) {
await bot.sendMessage(chatId, '❌ Email не найден, введи вручную')
state.awaitingEmail = true
return
}
await doLogin(chatId, user.hhEmail)
},
hh_login_use_current_phone: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingPhone = false
state.loginPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
if (!user?.hhPhone) {
await bot.sendMessage(chatId, '❌ Телефон не найден, введи вручную')
state.awaitingPhone = true
return
}
await doLoginByPhone(chatId, user.hhPhone)
},
hh_keep_query: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingQuery = false
state.queryPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
},
hh_keep_max: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingMax = false
state.maxPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
},
hh_keep_prompt: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingPrompt = false
state.promptPromptMessageId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
},
hh_reset_prompt: async (chatId, messageId) => {
const state = getState(chatId)
state.awaitingPrompt = false
state.promptPromptMessageId = null
await prisma.user.update({ where: { telegramId: chatId }, data: { prompt: DEFAULT_PROMPT } })
await bot.deleteMessage(chatId, messageId).catch(() => {})
await bot.sendMessage(chatId, '✅ Промт сброшен на дефолтный')
},
ob_skip_max: async (chatId, messageId) => {
const state = getState(chatId)
state.onboardingStep = null
state.onboardingMsgId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
await showQueryStep(chatId)
},
ob_skip_query: async (chatId, messageId) => {
const state = getState(chatId)
state.onboardingStep = null
state.onboardingMsgId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
await showResumeInfo(chatId)
await showPromptStep(chatId)
},
ob_skip_prompt: async (chatId, messageId) => {
const state = getState(chatId)
state.onboardingStep = null
state.onboardingMsgId = null
await bot.deleteMessage(chatId, messageId).catch(() => {})
await finishOnboarding(chatId)
},
hh_resume_list: async (chatId, messageId) => {
await bot.deleteMessage(chatId, messageId).catch(() => {})
await handleResumeList(chatId)
},
}
await showMenu(chatId)
}
catch (e) {
await resetMenuToBottom(chatId)
await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`)
await showMenu(chatId)
}
async function clearAwaitingState(chatId: number): Promise<void> {
const state = getState(chatId)
const msgIds = [
state.loginMethodMsgId,
state.loginPromptMessageId,
state.queryPromptMessageId,
state.maxPromptMessageId,
state.promptPromptMessageId,
state.onboardingMsgId,
]
state.awaitingEmail = false
state.awaitingPhone = false
state.awaitingQuery = false
state.awaitingMax = false
state.awaitingPrompt = false
state.onboardingStep = null
state.onboardingMsgId = null
state.loginMethodMsgId = null
state.loginPromptMessageId = null
state.queryPromptMessageId = null
state.maxPromptMessageId = null
state.promptPromptMessageId = null
await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {})))
}
export async function triggerHHStart(chatId: number): Promise<void> {
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
if (!user?.session) {
const state = getState(chatId)
const msg = await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: LOGIN_MARKUP })
state.menuMessageId = msg.message_id
return
}
await showMenu(chatId)
const keyboard = user?.session ? MAIN_REPLY_KEYBOARD : LOGIN_REPLY_KEYBOARD
await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: keyboard })
}
export function registerHHCommands() {
bot.onText(/\/hhstart/, (msg) => {
triggerHHStart(msg.chat.id)
})
bot.onText(/\/hhstart/, msg => triggerHHStart(msg.chat.id))
bot.on('callback_query', async (query) => {
if (!query.message)
@@ -158,258 +171,88 @@ export function registerHHCommands() {
const chatId = query.message.chat.id
const messageId = query.message.message_id
const state = getState(chatId)
await bot.answerCallbackQuery(query.id).catch(() => {})
const user = await prisma.user.findUnique({
where: { telegramId: chatId },
include: { Settings: true },
})
const settings = user!.Settings!
const exactHandler = query.data ? CALLBACK_HANDLERS[query.data] : undefined
if (exactHandler) {
await exactHandler(chatId, messageId)
return
}
switch (query.data) {
case 'hh_back':
await showMenu(chatId, messageId)
break
case 'hh_apply': {
await bot.deleteMessage(chatId, messageId).catch(() => {})
state.menuMessageId = null
const reporter = createStatusReporter(chatId)
await reporter.status(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`)
applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter })
.then(async (result) => {
if (result.error) {
await bot.sendMessage(chatId, `${result.error}`)
}
else {
const lines: string[] = []
lines.push(`📊 <b>Итого по запросу «${settings.searchQuery}»</b>`)
lines.push(`✅ Откликнулся: ${result.applied.length}`)
lines.push(`⏭ Пропущено: ${result.skipped.length}`)
if (result.errors.length)
lines.push(`❌ Ошибок: ${result.errors.length}`)
if (result.skipped.length) {
lines.push('')
lines.push('⏭ <b>Пропущенные:</b>')
result.skipped.forEach(v => lines.push(`• <a href="${v.href}">${v.title}</a>`))
}
if (result.errors.length) {
lines.push('')
lines.push('❌ <b>Ошибки:</b>')
result.errors.forEach(v => lines.push(`• <a href="${v.href}">${escapeHtml(v.title)}</a> — ${escapeHtml(v.message ?? '')}`))
}
const fullText = lines.join('\n')
const LIMIT = 4000
for (let i = 0; i < fullText.length; i += LIMIT) {
await bot.sendMessage(chatId, fullText.slice(i, i + LIMIT), {
parse_mode: 'HTML',
disable_web_page_preview: true,
})
}
}
await showMenu(chatId)
})
break
}
case 'hh_status': {
const isAuth = await checkIsAuth(chatId)
await showResult(
chatId,
messageId,
`⚙️ Настройки:\n\nЗапрос: ${settings.searchQuery}\nМакс откликов: ${settings.maxApplies}\nАвто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\nАвторизован: ${isAuth ? '✅' : '❌'}`,
)
break
}
case 'hh_my_resume': {
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
}
const MAX = 3500
const text = resume.data.length > MAX
? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)`
: resume.data
await safeEdit(
`📋 <b>Твоё резюме:</b>\n <b>${resume.title}</b>\n<pre>${escapeHtml(text)}</pre>`,
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'HTML',
reply_markup: BACK_MARKUP,
},
)
break
}
case 'hh_login':
state.menuMessageId = messageId
if (!user?.hhEmail) {
state.awaitingEmail = true
await bot.sendMessage(chatId, '📧 Введи email от hh.ru:')
}
else {
state.awaitingEmail = true
const prompt = await bot.sendMessage(
chatId,
`📧 Текущий email: <b>${user.hhEmail}</b>\n\спользовать его или введи другой:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' },
]],
},
},
)
state.loginPromptMessageId = prompt.message_id
}
break
case 'hh_login_use_current': {
state.awaitingEmail = false
await bot.deleteMessage(chatId, messageId).catch(() => {})
state.loginPromptMessageId = null
const email = user?.hhEmail
if (!email) {
await bot.sendMessage(chatId, '❌ Email не найден, введи вручную')
state.awaitingEmail = true
break
}
await doLogin(chatId, email)
break
}
case 'hh_query':
{
state.awaitingQuery = true
state.menuMessageId = messageId
const q = await prisma.settings.findFirst({
where: { telegramId: chatId },
})
await bot.sendMessage(chatId, `🔍Текущий запрос: ${q?.searchQuery || '--'}`)
await bot.sendMessage(chatId, '🔍 Введи поисковый запрос:')
break
}
case 'hh_max':
state.awaitingMax = true
state.menuMessageId = messageId
await bot.sendMessage(chatId, '🔢 Введи максимальное количество откликов (1-50):')
break
case 'hh_auto_start':
if (state.autoCron) {
await showResult(chatId, messageId, '⚠️ Авто уже запущено')
break
}
state.autoCron = cron.schedule('0 10 * * 1-5', async () => {
await bot.sendMessage(chatId, '⏰ Авто-отклик...')
})
await showResult(chatId, messageId, '✅ Авто включён (пн-пт, 10:00)')
break
case 'hh_auto_stop':
state.autoCron?.stop()
state.autoCron = null
await showResult(chatId, messageId, '⛔ Авто остановлен')
break
case 'hh_skipped': {
const skipped = await prisma.skippedVacancy.findMany({
where: { telegramId: chatId },
orderBy: { createdAt: 'desc' },
take: 50,
})
if (!skipped.length) {
await showResult(chatId, messageId, '✅ Проблемных вакансий нет')
break
}
const lines = ['🚫 <b>Вакансии с опросником (бот не может откликнуться):</b>', '']
skipped.forEach(v => lines.push(`• <a href="${escapeHtml(v.href)}">${escapeHtml(v.title)}</a>`))
await safeEdit(lines.join('\n'), {
chat_id: chatId,
message_id: messageId,
parse_mode: 'HTML',
disable_web_page_preview: true,
reply_markup: BACK_MARKUP,
})
break
}
case 'hh_resume_list': {
await safeEdit('🔄 Загружаю список резюме...', {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
const resumes = await listResumes(chatId)
if (resumes.length === 0) {
await showResult(chatId, messageId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru')
}
else if (resumes.length === 1) {
await safeEdit('🔄 Сохраняю резюме...', {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
await saveResume(chatId, resumes[0])
await showResult(chatId, messageId, `✅ Резюме сохранено: ${resumes[0].title}`)
}
else {
state.menuMessageId = messageId
await sendResumeSelector(chatId, resumes, messageId)
}
break
}
default: {
if (query.data?.startsWith('hh_resume_pick_')) {
const idx = Number(query.data.replace('hh_resume_pick_', ''))
const resume = state.pendingResumes[idx]
if (!resume) {
await showResult(chatId, messageId, '❌ Резюме не найдено, попробуйте снова')
break
}
await safeEdit('🔄 Сохраняю резюме...', {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
await saveResume(chatId, resume)
state.pendingResumes = []
await showResult(chatId, messageId, `✅ Резюме выбрано: ${resume.title}`)
}
break
}
if (query.data?.startsWith('hh_resume_pick_')) {
const idx = Number(query.data.replace('hh_resume_pick_', ''))
await handleResumePick(chatId, messageId, idx)
}
})
bot.on('message', async (msg) => {
const chatId = msg.chat.id
if (!msg.text || msg.text.startsWith('/'))
return
const chatId = msg.chat.id
const state = getState(chatId)
const user = await prisma.user.findUnique({
where: { telegramId: chatId },
include: { Settings: true },
})
if (state.isApplying) {
await bot.sendMessage(chatId, '⏳ Подождите, идут отклики на вакансии...')
return
}
const isAwaiting = state.awaitingEmail || state.awaitingPhone || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt || state.onboardingStep !== null
const isMenuButton = Object.values(BTN).includes(msg.text as typeof BTN[keyof typeof BTN])
if (isMenuButton && isAwaiting) {
await clearAwaitingState(chatId)
}
if (state.onboardingStep === 'max') {
const num = Number(msg.text)
if (Number.isNaN(num) || num < 1 || num > 50) {
await bot.sendMessage(chatId, '❌ Введи число от 1 до 50:')
return
}
state.onboardingStep = null
if (state.onboardingMsgId) {
await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {})
state.onboardingMsgId = null
}
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
await prisma.settings.update({ where: { telegramId: chatId }, data: { maxApplies: num } })
await bot.sendMessage(chatId, `✅ Макс откликов: ${num}`)
await showQueryStep(chatId)
return
}
if (state.onboardingStep === 'query') {
state.onboardingStep = null
if (state.onboardingMsgId) {
await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {})
state.onboardingMsgId = null
}
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } })
await bot.sendMessage(chatId, `✅ Запрос: «${updated.searchQuery}»`)
await showResumeInfo(chatId)
await showPromptStep(chatId)
return
}
if (state.onboardingStep === 'prompt') {
state.onboardingStep = null
if (state.onboardingMsgId) {
await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {})
state.onboardingMsgId = null
}
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
await prisma.user.upsert({
where: { telegramId: chatId },
update: { prompt: msg.text },
create: { telegramId: chatId, prompt: msg.text, Settings: { create: {} } },
})
await bot.sendMessage(chatId, '✅ Промт сохранён')
await finishOnboarding(chatId)
return
}
if (state.awaitingEmail) {
state.awaitingEmail = false
@@ -422,15 +265,26 @@ export function registerHHCommands() {
return
}
if (state.awaitingPhone) {
state.awaitingPhone = false
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
if (state.loginPromptMessageId) {
await bot.deleteMessage(chatId, state.loginPromptMessageId).catch(() => {})
state.loginPromptMessageId = null
}
await doLoginByPhone(chatId, msg.text)
return
}
if (state.awaitingQuery) {
state.awaitingQuery = false
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
const updated = await prisma.settings.update({
where: { telegramId: chatId },
data: { searchQuery: msg.text },
})
if (state.queryPromptMessageId) {
await bot.deleteMessage(chatId, state.queryPromptMessageId).catch(() => {})
state.queryPromptMessageId = null
}
const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } })
await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`)
await showMenu(chatId)
return
}
@@ -442,12 +296,31 @@ export function registerHHCommands() {
}
state.awaitingMax = false
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
const updated = await prisma.settings.update({
where: { telegramId: chatId },
data: { maxApplies: num },
})
if (state.maxPromptMessageId) {
await bot.deleteMessage(chatId, state.maxPromptMessageId).catch(() => {})
state.maxPromptMessageId = null
}
const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { maxApplies: num } })
await bot.sendMessage(chatId, `✅ Макс откликов: ${updated.maxApplies}`)
await showMenu(chatId)
return
}
if (state.awaitingPrompt) {
state.awaitingPrompt = false
await bot.deleteMessage(chatId, msg.message_id).catch(() => {})
if (state.promptPromptMessageId) {
await bot.deleteMessage(chatId, state.promptPromptMessageId).catch(() => {})
state.promptPromptMessageId = null
}
await prisma.user.upsert({
where: { telegramId: chatId },
update: { prompt: msg.text },
create: { telegramId: chatId, prompt: msg.text, Settings: { create: {} } },
})
await bot.sendMessage(chatId, '✅ Промт сохранён')
return
}
await MESSAGE_HANDLERS[msg.text]?.(chatId)
})
}

View File

@@ -1,5 +1,47 @@
import process from 'node:process'
import prisma from '@prisma'
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 {
return min + Math.random() * (max - min)
@@ -15,7 +57,29 @@ export async function randomScroll(page: Page): Promise<void> {
}
export async function getBrowser(): Promise<Browser> {
return chromium.launch({ headless: false })
return chromium.launch({
headless: !process.env.debug,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-blink-features=AutomationControlled',
],
})
}
export async function newStealthContext(browser: Browser) {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 },
locale: 'ru-RU',
timezoneId: 'Europe/Moscow',
})
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false })
})
return context
}
export async function loadSession(page: Page, telegramId: bigint | number): Promise<boolean> {
@@ -25,3 +89,15 @@ export async function loadSession(page: Page, telegramId: bigint | number): Prom
await page.context().addCookies(JSON.parse(user.session))
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()
}
})
}

59
src/hh/handlers/apply.ts Normal file
View File

@@ -0,0 +1,59 @@
import bot from '@bot'
import prisma from '@prisma'
import { applyToJobs } from '../scraper.js'
import { APPLYING_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, createStatusReporter, escapeHtml } from '../ui.js'
import { getState } from '../state.js'
export async function handleApply(chatId: number): Promise<void> {
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
if (!settings)
return
const state = getState(chatId)
state.isApplying = true
const reporter = createStatusReporter(chatId)
await bot.sendMessage(chatId, `🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`, { reply_markup: APPLYING_REPLY_KEYBOARD })
applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter })
.then(async (result) => {
state.isApplying = false
await bot.sendMessage(chatId, '✅ Готово', { reply_markup: MAIN_REPLY_KEYBOARD })
if (result.error) {
await bot.sendMessage(chatId, `${result.error}`)
return
}
const lines: string[] = []
lines.push(`📊 <b>Итого по запросу «${settings.searchQuery}»</b>`)
lines.push(`✅ Откликнулся: ${result.applied.length}`)
lines.push(`⏭ Пропущено: ${result.skipped.length}`)
if (result.errors.length)
lines.push(`❌ Ошибок: ${result.errors.length}`)
if (result.skipped.length) {
lines.push('')
lines.push('⏭ <b>Пропущенные:</b>')
result.skipped.forEach(v => lines.push(`• <a href="${v.href}">${v.title}</a>`))
}
if (result.errors.length) {
lines.push('')
lines.push('❌ <b>Ошибки:</b>')
result.errors.forEach(v => lines.push(`• <a href="${v.href}">${escapeHtml(v.title)}</a> — ${escapeHtml(v.message ?? '')}`))
}
const fullText = lines.join('\n')
const LIMIT = 4000
for (let i = 0; i < fullText.length; i += LIMIT) {
await bot.sendMessage(chatId, fullText.slice(i, i + LIMIT), {
parse_mode: 'HTML',
disable_web_page_preview: true,
})
}
})
.catch(async () => {
state.isApplying = false
await bot.sendMessage(chatId, '❌ Ошибка при откликах', { reply_markup: MAIN_REPLY_KEYBOARD })
})
}

138
src/hh/handlers/auth.ts Normal file
View File

@@ -0,0 +1,138 @@
import bot from '@bot'
import prisma from '@prisma'
import { listResumes, login, loginByPhone, saveResume } from '../scraper.js'
import { getState } from '../state.js'
import { startOnboarding } from './onboarding.js'
import type { ResumeListItem } from '../types.js'
async function handlePostLogin(chatId: number): Promise<void> {
const state = getState(chatId)
let resumes: ResumeListItem[] | null = null
try {
resumes = await listResumes(chatId)
}
catch {
await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню')
}
await bot.sendMessage(chatId, '✅ Вход выполнен!')
if (resumes === null || resumes.length === 0) {
if (resumes?.length === 0)
await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru')
await startOnboarding(chatId)
}
else if (resumes.length === 1) {
await saveResume(chatId, resumes[0])
await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`)
await startOnboarding(chatId)
}
else {
state.pendingResumes = resumes
state.onboardingAfterResume = true
await bot.sendMessage(chatId, '📄 Выбери резюме:', {
reply_markup: {
inline_keyboard: [
...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]),
[{ text: '◀️ Закрыть', callback_data: 'hh_back' }],
],
},
})
}
}
export async function doLogin(chatId: number, email: string): Promise<void> {
await bot.sendMessage(chatId, '🔄 Логинюсь...')
try {
await login(email, chatId)
await prisma.user.upsert({
where: { telegramId: chatId },
update: { hhEmail: email },
create: { telegramId: chatId, hhEmail: email, Settings: { create: {} } },
})
await handlePostLogin(chatId)
}
catch (e) {
await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`)
}
}
export async function doLoginByPhone(chatId: number, phone: string): Promise<void> {
await bot.sendMessage(chatId, '🔄 Логинюсь...')
try {
await loginByPhone(phone, chatId)
await prisma.user.upsert({
where: { telegramId: chatId },
update: { hhPhone: phone },
create: { telegramId: chatId, hhPhone: phone, Settings: { create: {} } },
})
await handlePostLogin(chatId)
}
catch (e) {
await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`)
}
}
export async function handleLogin(chatId: number): Promise<void> {
const state = getState(chatId)
const msg = await bot.sendMessage(chatId, '🔐 Выбери способ входа:', {
reply_markup: {
inline_keyboard: [[
{ text: '📧 Email', callback_data: 'hh_login_method_email' },
{ text: '📱 Телефон', callback_data: 'hh_login_method_phone' },
]],
},
})
state.loginMethodMsgId = msg.message_id
}
export async function handleLoginByEmail(chatId: number): Promise<void> {
const state = getState(chatId)
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
state.awaitingEmail = true
if (!user?.hhEmail) {
await bot.sendMessage(chatId, '📧 Введи email от hh.ru:')
}
else {
const prompt = await bot.sendMessage(
chatId,
`📧 Текущий email: <b>${user.hhEmail}</b>\n\спользовать его или введи другой:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' },
]],
},
},
)
state.loginPromptMessageId = prompt.message_id
}
}
export async function handleLoginByPhone(chatId: number): Promise<void> {
const state = getState(chatId)
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
state.awaitingPhone = true
if (!user?.hhPhone) {
await bot.sendMessage(chatId, '📱 Введи номер телефона (например: +79001234567):')
}
else {
const prompt = await bot.sendMessage(
chatId,
`📱 Текущий телефон: <b>${user.hhPhone}</b>\n\спользовать его или введи другой:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `✅ Войти как ${user.hhPhone}`, callback_data: 'hh_login_use_current_phone' },
]],
},
},
)
state.loginPromptMessageId = prompt.message_id
}
}

6
src/hh/handlers/debug.ts Normal file
View File

@@ -0,0 +1,6 @@
import bot from '@bot'
export async function debugFunc(chatId: number): Promise<void> {
await bot.sendMessage(chatId, '🌍 Регион — скоро будет доступно')
// await startOnboarding(chatId)
}

37
src/hh/handlers/info.ts Normal file
View File

@@ -0,0 +1,37 @@
import bot from '@bot'
import prisma from '@prisma'
import { checkIsAuth } from '../scraper.js'
import { getState } from '../state.js'
import { escapeHtml } from '../ui.js'
export async function handleStatus(chatId: number): Promise<void> {
const state = getState(chatId)
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
const isAuth = await checkIsAuth(chatId)
await bot.sendMessage(
chatId,
`⚙️ Настройки:\n\nЗапрос: ${settings?.searchQuery ?? '--'}\nМакс откликов: ${settings?.maxApplies ?? '--'}\nАвто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\nАвторизован: ${isAuth ? '✅' : '❌'}`,
{ reply_markup: { inline_keyboard: [] } },
)
}
export async function handleSkipped(chatId: number): Promise<void> {
const skipped = await prisma.skippedVacancy.findMany({
where: { telegramId: chatId },
orderBy: { createdAt: 'desc' },
take: 50,
})
if (!skipped.length) {
await bot.sendMessage(chatId, '✅ Проблемных вакансий нет')
return
}
const lines = ['🚫 <b>Вакансии с опросником (бот не может откликнуться):</b>', '']
skipped.forEach(v => lines.push(`• <a href="${escapeHtml(v.href)}">${escapeHtml(v.title)}</a>`))
await bot.sendMessage(chatId, lines.join('\n'), {
parse_mode: 'HTML',
disable_web_page_preview: true,
reply_markup: { inline_keyboard: [] },
})
}

View File

@@ -0,0 +1,119 @@
import bot from '@bot'
import prisma from '@prisma'
import { getState } from '../state.js'
import { escapeHtml, MAIN_REPLY_KEYBOARD } from '../ui.js'
import { DEFAULT_PROMPT } from './settings.js'
export async function startOnboarding(chatId: number): Promise<void> {
await bot.sendMessage(
chatId,
`👋 <b>Давай настроим бота</b> — займёт меньше минуты.\n\ройдём по ключевым параметрам.`,
{ parse_mode: 'HTML' },
)
await showMaxStep(chatId)
}
export async function showMaxStep(chatId: number): Promise<void> {
const state = getState(chatId)
state.onboardingStep = 'max'
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const current = settings?.maxApplies ?? 1
const msg = await bot.sendMessage(
chatId,
`🔢 <b>Шаг 1 из 3 — Максимум откликов</b>\n\n`
+ `Сколько вакансий бот обработает за один запуск. Рекомендуем начать с небольшого числа, чтобы проверить письма.\n\n`
+ `Можно оставить текущее значение: <b>${current}</b> или ввести число в чат от 1 до 50:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `Оставить ${current}`, callback_data: 'ob_skip_max' },
]],
},
},
)
state.onboardingMsgId = msg.message_id
}
export async function showQueryStep(chatId: number): Promise<void> {
const state = getState(chatId)
state.onboardingStep = 'query'
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const current = settings?.searchQuery || 'Vue'
const msg = await bot.sendMessage(
chatId,
`🔍 <b>Шаг 2 из 3 — Поисковый запрос</b>\n\n`
+ `По этому запросу бот ищет вакансии на hh.ru. Используй профессию или ключевые навыки.\n\n`
+ `Текущий запрос: <b>${current}</b>\n\n`
+ `Введи новый или оставь текущий:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `Оставить «${current}» ✓`, callback_data: 'ob_skip_query' },
]],
},
},
)
state.onboardingMsgId = msg.message_id
}
export async function showResumeInfo(chatId: number): Promise<void> {
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const resume = settings?.selectedResumeId
? await prisma.resume.findUnique({ where: { id: settings.selectedResumeId } })
: await prisma.resume.findFirst({ where: { telegramId: chatId } })
if (resume) {
await bot.sendMessage(
chatId,
`📄 <b>Резюме</b>\n\nАктивное резюме: <b>${resume.title}</b>\n\nЕсли у тебя несколько резюме на hh.ru — можно выбрать нужное через <i>Настройки → Выбрать резюме</i>.`,
{ parse_mode: 'HTML' },
)
}
else {
await bot.sendMessage(
chatId,
`📄 <b>Резюме</b>\n\nРезюме пока не выбрано. Создай его на hh.ru, затем выбери через <i>Настройки → Выбрать резюме</i>.`,
{ parse_mode: 'HTML' },
)
}
}
export async function showPromptStep(chatId: number): Promise<void> {
const state = getState(chatId)
state.onboardingStep = 'prompt'
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
const currentPrompt = user?.prompt || DEFAULT_PROMPT
const msg = await bot.sendMessage(
chatId,
`📝 <b>Шаг 3 из 3 — Промт для AI</b>\n\n`
+ `Инструкция, которую AI получает при написании сопроводительного письма. `
+ `Задаёт стиль, тон и то, что важно упомянуть.\n\n`
+ `<i>Текущий промт:</i>\n<pre>${escapeHtml(currentPrompt)}</pre>\n\n`
+ `Введи свой или оставь дефолтный:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: 'Оставить дефолтный ✓', callback_data: 'ob_skip_prompt' },
]],
},
},
)
state.onboardingMsgId = msg.message_id
}
export async function finishOnboarding(chatId: number): Promise<void> {
const state = getState(chatId)
state.onboardingStep = null
state.onboardingMsgId = null
await bot.sendMessage(
chatId,
`🎉 <b>Настройка завершена!</b>\n\nВсё готово — нажми <b>🚀 Откликнуться</b>, чтобы запустить бота.\n\ополнительные параметры (запрос, регион, слова-исключения) доступны в разделе <b>Фильтры</b>.`,
{ parse_mode: 'HTML', reply_markup: MAIN_REPLY_KEYBOARD },
)
}

108
src/hh/handlers/resume.ts Normal file
View File

@@ -0,0 +1,108 @@
import bot from '@bot'
import prisma from '@prisma'
import { createLogger } from '@/logger'
import { listResumes, NoResumeError, saveResume } from '../scraper.js'
import { getState } from '../state.js'
import { escapeHtml, NO_RESUME_MARKUP, safeEdit } from '../ui.js'
import { startOnboarding } from './onboarding.js'
const log = createLogger('resume')
export async function handleResumeList(chatId: number): Promise<void> {
const state = getState(chatId)
const loadingMsg = await bot.sendMessage(chatId, '🔄 Загружаю список резюме...')
let resumes
try {
resumes = await listResumes(chatId)
log.ok(`handleResumeList chatId=${chatId}:`, resumes)
}
catch (e) {
await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {})
if (e instanceof NoResumeError) {
await bot.sendMessage(
chatId,
'📝 Резюме не найдено.\n\nСоздайте резюме на <a href="https://hh.ru/applicant/resumes/new">hh.ru</a>, затем нажмите <b>Повторить</b>.',
{ parse_mode: 'HTML', reply_markup: NO_RESUME_MARKUP },
)
}
else {
await bot.sendMessage(chatId, '❌ Не удалось загрузить резюме. Попробуйте войти заново через «Войти на hh.ru».')
}
return
}
await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {})
if (resumes.length === 0) {
await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru')
}
else if (resumes.length === 1) {
await saveResume(chatId, resumes[0])
await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`)
}
else {
state.pendingResumes = resumes
await bot.sendMessage(chatId, '📄 Выбери резюме:', {
reply_markup: {
inline_keyboard: [
...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]),
[{ text: '◀️ Закрыть', callback_data: 'hh_back' }],
],
},
})
}
}
export async function handleMyResume(chatId: number): Promise<void> {
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
const resume = settings?.selectedResumeId
? await prisma.resume.findUnique({ where: { id: settings.selectedResumeId } })
: await prisma.resume.findFirst({ where: { telegramId: chatId } })
if (!resume) {
await bot.sendMessage(chatId, '📋 Резюме не найдено.\n\nВыбери резюме через кнопку 📄 Выбрать резюме.')
return
}
const MAX = 3500
const text = resume.data.length > MAX
? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)`
: resume.data
await bot.sendMessage(
chatId,
`📋 <b>Твоё резюме:</b>\n<b>${resume.title}</b>\n<pre>${escapeHtml(text)}</pre>`,
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
)
}
export async function handleResumePick(chatId: number, messageId: number, idx: number): Promise<void> {
const state = getState(chatId)
const resume = state.pendingResumes[idx]
if (!resume) {
await safeEdit('❌ Резюме не найдено, попробуйте снова', {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
return
}
await safeEdit('🔄 Сохраняю резюме...', {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
await saveResume(chatId, resume)
state.pendingResumes = []
await safeEdit(`✅ Резюме выбрано: ${resume.title}`, {
chat_id: chatId,
message_id: messageId,
reply_markup: { inline_keyboard: [] },
})
if (state.onboardingAfterResume) {
state.onboardingAfterResume = false
await startOnboarding(chatId)
}
}

View File

@@ -0,0 +1,83 @@
import bot from '@bot'
import prisma from '@prisma'
import cron from 'node-cron'
import { SETTINGS_REPLY_KEYBOARD, escapeHtml } from '../ui.js'
import { getState } from '../state.js'
export async function handleQuery(chatId: number): Promise<void> {
const state = getState(chatId)
state.awaitingQuery = true
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const currentQuery = settings?.searchQuery || '--'
const msg = await bot.sendMessage(
chatId,
`🔍 Текущий запрос: <b>${escapeHtml(currentQuery)}</b>\n\nВведи новый или оставь текущий:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `✅ Оставить «${currentQuery}»`, callback_data: 'hh_keep_query' },
]],
},
},
)
state.queryPromptMessageId = msg.message_id
}
export async function handleMax(chatId: number): Promise<void> {
const state = getState(chatId)
state.awaitingMax = true
const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } })
const currentMax = settings?.maxApplies ?? '--'
const msg = await bot.sendMessage(
chatId,
`🔢 Текущее значение: <b>${currentMax}</b>\n\nВведи новое количество откликов (150) или оставь текущее:`,
{
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: `✅ Оставить ${currentMax}`, callback_data: 'hh_keep_max' },
]],
},
},
)
state.maxPromptMessageId = msg.message_id
}
export const DEFAULT_PROMPT = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
export async function handlePrompt(chatId: number): Promise<void> {
const state = getState(chatId)
state.awaitingPrompt = true
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
const currentPrompt = user?.prompt
const text = currentPrompt
? `📝 Текущий промт:\n<pre>${escapeHtml(currentPrompt)}</pre>\n\nВведи новый или оставь текущий:`
: '📝 Введи промт для AI (пока не задан):'
const buttons: { text: string; callback_data: string }[] = []
if (currentPrompt) {
buttons.push({ text: '✅ Оставить текущий', callback_data: 'hh_keep_prompt' })
if (currentPrompt !== DEFAULT_PROMPT)
buttons.push({ text: '🔄 Вернуть дефолтный', callback_data: 'hh_reset_prompt' })
}
const msg = await bot.sendMessage(chatId, text, {
parse_mode: 'HTML',
reply_markup: { inline_keyboard: buttons.length ? [buttons] : [] },
})
state.promptPromptMessageId = msg.message_id
}
export async function handleAutoToggle(chatId: number): Promise<void> {
const state = getState(chatId)
if (state.autoCron) {
state.autoCron.stop()
state.autoCron = null
await bot.sendMessage(chatId, '⛔ Авто остановлен', { reply_markup: SETTINGS_REPLY_KEYBOARD })
}
else {
state.autoCron = cron.schedule('0 10 * * 1-5', async () => {
await bot.sendMessage(chatId, '⏰ Авто-отклик...')
})
await bot.sendMessage(chatId, '✅ Авто включён (пн-пт, 10:00)', { reply_markup: SETTINGS_REPLY_KEYBOARD })
}
}

View File

@@ -1,11 +1,22 @@
import type { Message } from 'node-telegram-bot-api'
import type { BrowserContext, 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 { getBrowser, loadSession, randomDelay, randomScroll } from './browser.js'
import { escapeHtml } from './ui.js'
import { createLogger } from '@/logger'
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')
this.name = 'NoResumeError'
}
}
function waitForOtp(chatId: number): Promise<string> {
return new Promise((resolve) => {
@@ -19,152 +30,219 @@ function waitForOtp(chatId: number): Promise<string> {
})
}
export async function login(email: string, chatId: number): Promise<void> {
const browser = await getBrowser()
// await bot.sendMessage(chatId, `Browser is connected: ${browser.isConnected()}`)
if (!browser.version()) {
console.log('browser error')
return
async function handleOtpFlow(page: Page, context: BrowserContext, chatId: number, initialMessage: string): Promise<void> {
await page.click('[data-qa="applicant-login-input-otp"]')
let message = initialMessage
while (true) {
await bot.sendMessage(chatId, message)
const otp = await waitForOtp(chatId)
await page.fill('[data-qa="applicant-login-input-otp"] input', otp)
const outcome = await Promise.race([
page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 }).then(() => 'success' as const),
page.waitForSelector('[data-qa="magritte-pincode-input-field"][aria-invalid="true"]', { timeout: 8000 }).then(() => 'error' as const),
]).catch(() => 'timeout' as const)
if (outcome === 'success')
break
if (outcome === 'error') {
message = '❌ Неверный код. Введи код ещё раз:'
continue
}
throw new Error('OTP verification timed out')
}
const context = await browser.newContext()
const page = await context.newPage()
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.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Клик по "Войти"`)
await page.click('label:has([data-qa="credential-type-EMAIL"])')
await page.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Клик по "Email"`)
await page.fill('[data-qa="applicant-login-input-email"]', email)
await page.waitForTimeout(randomDelay())
// await bot.sendMessage(chatId, `Ввод "Email"`)
await page.click('[data-qa="submit-button"]')
// await bot.sendMessage(chatId, `Клик по "Дальше"`)
await page.waitForTimeout(randomDelay())
await bot.sendMessage(chatId, '🔑 Введи код из email')
await page.waitForTimeout(randomDelay())
await page.click('[data-qa="applicant-login-input-otp"]')
const otp = await waitForOtp(chatId)
await page.fill('[data-qa="applicant-login-input-otp"] input', otp)
// await bot.sendMessage(chatId, `Введён ОТП: ${otp}`)
await page.waitForTimeout(randomDelay())
await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 })
const cookies = await context.cookies()
await prisma.user.update({
await prisma.user.upsert({
where: { telegramId: chatId },
data: { session: JSON.stringify(cookies, null, 2) },
update: { session: JSON.stringify(cookies, null, 2) },
create: { telegramId: chatId, session: JSON.stringify(cookies, null, 2), Settings: { create: {} } },
})
await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка')
await browser.close()
}
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
const { keep } = createStatusReporter(chatId)
await keep(`Пропущена вакансия: ${vacancy.title}`)
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 },
update: {},
})
results.skipped.push(ref)
return true
}
export async function loginByPhone(phone: string, chatId: number): Promise<void> {
await withBrowser(async (browser) => {
if (!browser.version()) {
log.error('browser error')
return
}
const context = await newStealthContext(browser)
const page = await context.newPage()
await page.goto('https://hh.ru/account/login', { waitUntil: 'domcontentloaded' })
await page.click('[data-qa="submit-button"]')
await page.waitForTimeout(randomDelay())
await page.fill('[data-qa="magritte-phone-input-national-number-input"]', phone)
await page.waitForTimeout(randomDelay())
await page.click('[data-qa="submit-button"]')
await page.waitForTimeout(randomDelay())
await handleOtpFlow(page, context, chatId, '🔑 Введи код из SMS')
})
}
export async function login(email: string, chatId: number): Promise<void> {
await withBrowser(async (browser) => {
if (!browser.version()) {
log.error('browser error')
return
}
const context = await newStealthContext(browser)
const page = await context.newPage()
await page.goto('https://hh.ru/account/login', { waitUntil: 'domcontentloaded' })
await page.click('[data-qa="submit-button"]')
await page.waitForTimeout(randomDelay())
await page.click('label:has([data-qa="credential-type-EMAIL"])')
await page.waitForTimeout(randomDelay())
await page.fill('[data-qa="applicant-login-input-email"]', email)
await page.waitForTimeout(randomDelay())
await page.click('[data-qa="submit-button"]')
await page.waitForTimeout(randomDelay())
await handleOtpFlow(page, context, chatId, '🔑 Введи код из email')
})
}
export async function checkIsAuth(telegramId: bigint | number) {
const browser = await getBrowser()
const context = await browser.newContext()
const page = await context.newPage()
await loadSession(page, telegramId)
await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' })
try {
return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 })
}
catch {
return null
}
finally {
await browser.close()
}
return withBrowser(async (browser) => {
const context = await newStealthContext(browser)
const page = await context.newPage()
await loadSession(page, telegramId)
await page.goto('https://hh.ru/search/vacancy', { waitUntil: 'domcontentloaded' })
try {
return await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 5000 })
}
catch {
return null
}
})
}
export async function listResumes(chatId: number): Promise<ResumeListItem[]> {
const browser = await getBrowser()
const context = await browser.newContext()
const page = await context.newPage()
await loadSession(page, chatId)
return withBrowser(async (browser) => {
const context = await newStealthContext(browser)
const page = await context.newPage()
await loadSession(page, chatId)
let lastError: Error | null = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' })
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.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 },
})
let lastError: Error | null = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await page.goto('https://hh.ru/applicant/resumes', { waitUntil: 'domcontentloaded' })
const finalUrl = page.url()
if (finalUrl.includes('/profile/resume/professional_role')) {
throw new NoResumeError()
}
catch (e) {
console.log(`Failed to fetch resume text for ${item.title}:`, e)
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() ?? '(Ошибка в получении названия)',
}
}),
)
log.info('resumes found:', 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) {
log.error(`Failed to fetch resume text for ${item.title}:`, e)
}
}
log.ok('listResumes:', 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) {
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> {
@@ -175,38 +253,70 @@ export async function saveResume(chatId: number, resumeItem: ResumeListItem): Pr
})
}
async function collectPageVacancies(page: Page) {
return page.$$eval(
'[data-qa="vacancy-serp__vacancy"]',
(cards) => {
return cards
.filter(card => card.querySelector('[data-qa="vacancy-serp__vacancy_response"]') !== null)
.map((card) => {
const titleEl = card.querySelector('[data-qa="serp-item__title"]') as HTMLAnchorElement | null
return {
href: titleEl?.href ?? '',
title: titleEl?.textContent?.trim() ?? '',
}
})
.filter(v => v.href)
},
)
}
export async function applyToJobs(
{ query, area = 1, maxApplies = 10 }: ApplyOptions,
{ chatId, reporter }: { chatId: number, reporter: StatusReporter },
): Promise<ApplyResult> {
const browser = await getBrowser()
const context = await browser.newContext()
const page = await context.newPage()
const results: ApplyResult = { applied: [], skipped: [], errors: [] }
const { status, keep, clear } = reporter
return withBrowser(async (browser) => {
const context = await newStealthContext(browser)
const page = await context.newPage()
const results: ApplyResult = { applied: [], skipped: [], errors: [] }
const { status, keep, clear } = reporter
try {
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)}&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()
if (!await page.$('[data-qa="profileAndResumes-button"]')) {
return { ...results, error: 'Не авторизован. Выполните login' }
}
await status('✅ Авторизация выполнена')
const vacancies = await page.$$eval(
'[data-qa="serp-item__title"]',
links => links.map(a => ({
href: (a as HTMLAnchorElement).href,
title: a.textContent?.trim() ?? '',
})),
)
const vacancies = await collectPageVacancies(page)
await status(`✅ Вакансий найдено: ${vacancies.length}`)
const pagerBlock = await page.$('[data-qa="pager-block"]')
if (pagerBlock) {
const maxPage = await page.$$eval(
'[data-qa="pager-block"] [data-qa="pager-page"]',
links => Math.max(...links.map((a) => {
const pageParam = new URL((a as HTMLAnchorElement).href).searchParams.get('page')
return Number(pageParam ?? 0)
})),
)
log.divider('CollectPageVacancies')
log.info('URL:', page.url())
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=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)
vacancies.push(...more)
}
}
await keep(`✅ Вакансий найдено: ${vacancies.length}`)
const resumes = await prisma.resume.findMany({ where: { telegramId: chatId } })
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
@@ -224,9 +334,16 @@ export async function applyToJobs(
)
let appliedCount = 0
let consecutiveSkips = 0
const MAX_CONSECUTIVE_SKIPS = 10
for (const vacancy of vacancies) {
if (appliedCount >= maxApplies)
break
if (consecutiveSkips >= MAX_CONSECUTIVE_SKIPS) {
log.warn(`Остановлено: ${MAX_CONSECUTIVE_SKIPS} пропусков подряд`)
await keep(`⚠️ Остановлено: ${MAX_CONSECUTIVE_SKIPS} вакансий подряд пропущено`)
break
}
const ref: VacancyRef = { title: vacancy.title, href: vacancy.href }
if (knownSkipped.has(vacancy.href)) {
@@ -234,7 +351,7 @@ export async function applyToJobs(
}
try {
await status(`🔄 Обрабатывается: ${vacancy.title}`)
await keep(`🔄 Обрабатывается: ${vacancy.title}`)
await page.goto(vacancy.href, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-qa="vacancy-description"]', { timeout: 10000 }).catch(() => null)
@@ -245,50 +362,57 @@ export async function applyToJobs(
if (!description) {
results.skipped.push(ref)
appliedCount++
consecutiveSkips++
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)
appliedCount++
consecutiveSkips++
continue
}
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)
const relocationConfirm = await page.waitForSelector('[data-qa="relocation-warning-confirm"]', { timeout: 2000 }).catch(() => null)
if (relocationConfirm)
await relocationConfirm.click()
if (await skipIfQuestionnaire(page, vacancy, ref, chatId, status, results)) {
appliedCount++
consecutiveSkips++
continue
}
// console.log('[LetterDebug]:', '\nresume: ', resume.data, '\ndescription: ', description, '\nprompt: ', user!.prompt)
await keep(`✍️ Генерирую письмо: ${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) => {
log.error('Letter Error:', err.message)
return null
})
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)
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()
// 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 +424,7 @@ export async function applyToJobs(
}
}
}
await page.pause()
// await page.pause()
const addLetter = await page.$('[data-qa="add-cover-letter"]')
@@ -308,16 +432,14 @@ export async function applyToJobs(
await addLetter?.hover()
await addLetter?.click()
}
const letter = await letterPromise
if (letter) {
await keep(`✅ <b>${escapeHtml(vacancy.title)}</b>\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(`Письмо не сгенерировано, ошибка`)
@@ -332,54 +454,62 @@ 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('single flow')
log.debug(`single flow: ${chatId}`)
const letter = await letterPromise
console.log('letter: ', letter)
if (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?.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())
}
else {
const errMsg = 'Not found submit button'
console.log(errMsg)
log.warn(errMsg)
results.errors.push({ ...ref, message: errMsg })
}
}
results.applied.push(ref)
appliedCount++
consecutiveSkips = 0
}
catch (err) {
results.errors.push({ ...ref, message: (err as Error).message })
}
}
await clear()
}
finally {
await browser.close()
}
// await clear()
return results
return results
})
}

50
src/hh/state.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { ResumeListItem } from './types.js'
import type { ScheduledTask } from 'node-cron'
export interface UserState {
autoCron: ScheduledTask | null
isApplying: boolean
awaitingEmail: boolean
awaitingPhone: boolean
awaitingQuery: boolean
awaitingMax: boolean
awaitingPrompt: boolean
pendingResumes: ResumeListItem[]
loginMethodMsgId: number | null
loginPromptMessageId: number | null
queryPromptMessageId: number | null
maxPromptMessageId: number | null
promptPromptMessageId: number | null
onboardingStep: 'max' | 'query' | 'prompt' | null
onboardingMsgId: number | null
onboardingAfterResume: boolean
}
function makeUserState(): UserState {
return {
autoCron: null,
isApplying: false,
awaitingEmail: false,
awaitingPhone: false,
awaitingQuery: false,
awaitingMax: false,
awaitingPrompt: false,
pendingResumes: [],
loginMethodMsgId: null,
loginPromptMessageId: null,
queryPromptMessageId: null,
maxPromptMessageId: null,
promptPromptMessageId: null,
onboardingStep: null,
onboardingMsgId: null,
onboardingAfterResume: false,
}
}
const states = new Map<number, UserState>()
export function getState(chatId: number): UserState {
if (!states.has(chatId))
states.set(chatId, makeUserState())
return states.get(chatId)!
}

View File

@@ -1,38 +1,87 @@
import bot from '@bot'
export const MAIN_MARKUP = {
inline_keyboard: [
[{ text: '🚀 Откликнуться сейчас', callback_data: 'hh_apply' }],
[
{ text: '🔍 Изменить запрос', callback_data: 'hh_query' },
{ text: '🔢 Макс откликов', callback_data: 'hh_max' },
],
[
{ text: '⏰ Авто вкл', callback_data: 'hh_auto_start' },
{ text: '⛔ Авто выкл', callback_data: 'hh_auto_stop' },
],
[
{ text: '🔑 Логин', callback_data: 'hh_login' },
{ text: '⚙️ Статус', callback_data: 'hh_status' },
],
[
{ text: '📄 Выбрать резюме', callback_data: 'hh_resume_list' },
{ text: '📋 Моё резюме', callback_data: 'hh_my_resume' },
],
[{ text: '🚫 Проблемные вакансии', callback_data: 'hh_skipped' }],
],
export const BTN = {
APPLY: '🚀 Откликнуться',
STATUS: '⚙️ Статус',
QUERY: '🔍 Изменить запрос',
EXCLUSIONS: '🚫 Слова исключения',
REGION: '🌍 Регион',
MAX: '🔢 Макс. откликов',
AUTO_TOGGLE: '⏰ Авто',
LOGIN: '🔑 Войти на hh.ru',
RESUME_LIST: '📄 Выбрать резюме',
MY_RESUME: '📋 Моё резюме',
SKIPPED: '🚫 Проблемные вакансии',
SETTINGS: '⚙️ Настройки',
FILTERS: '🔎 Фильтры',
INFO: ' Информация',
BACK: '◀️ Назад',
PROMPT: '📝 Промт',
} as const
export const LOGIN_REPLY_KEYBOARD = {
keyboard: [[{ text: BTN.LOGIN }]],
resize_keyboard: true,
persistent: true,
}
export const LOGIN_MARKUP = {
inline_keyboard: [
[{ text: '🔑 Войти через hh.ru', callback_data: 'hh_login' }],
export const MAIN_REPLY_KEYBOARD = {
keyboard: [
[{ text: BTN.APPLY }, { text: BTN.FILTERS }],
[{ text: BTN.SETTINGS }, { text: BTN.INFO }],
],
resize_keyboard: true,
persistent: true,
}
export const SETTINGS_REPLY_KEYBOARD = {
keyboard: [
[{ text: BTN.MAX }, { text: BTN.AUTO_TOGGLE }],
[{ text: BTN.RESUME_LIST }, { text: BTN.PROMPT }],
[{ text: BTN.LOGIN }],
[{ text: BTN.BACK }],
],
resize_keyboard: true,
persistent: true,
}
export const FILTERS_REPLY_KEYBOARD = {
keyboard: [
[{ text: BTN.QUERY }],
[{ text: BTN.EXCLUSIONS }, { text: BTN.REGION }],
[{ text: BTN.BACK }],
],
resize_keyboard: true,
persistent: true,
}
export const INFO_REPLY_KEYBOARD = {
keyboard: [
[{ text: BTN.STATUS }, { text: BTN.MY_RESUME }],
[{ text: BTN.SKIPPED }],
[{ text: BTN.BACK }],
],
resize_keyboard: true,
persistent: true,
}
export const APPLYING_REPLY_KEYBOARD = {
keyboard: [[{ text: '⏳ Откликаюсь на вакансии...' }]],
resize_keyboard: true,
persistent: true,
}
export const BACK_MARKUP = {
inline_keyboard: [[{ text: '◀️ Назад', callback_data: 'hh_back' }]],
}
export const NO_RESUME_MARKUP = {
inline_keyboard: [[
{ text: '🔄 Повторить', callback_data: 'hh_resume_list' },
{ text: '🔑 Другой аккаунт', callback_data: 'hh_login' },
]],
}
export function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
@@ -42,28 +91,24 @@ export async function safeEdit(
options: Parameters<typeof bot.editMessageText>[1],
): Promise<void> {
await bot.editMessageText(text, options).catch((e: unknown) => {
if (e instanceof Error && e.message.includes('message is not modified'))
if (e instanceof Error && (
e.message.includes('message is not modified')
|| e.message.includes('message to edit not found')
)) {
return
}
throw e
})
}
export async function showResult(chatId: number, messageId: number, text: string): Promise<void> {
await safeEdit(text, {
chat_id: chatId,
message_id: messageId,
reply_markup: BACK_MARKUP,
})
}
export interface StatusReporter {
status: (text: string) => Promise<void>
keep: (text: string) => Promise<void>
clear: () => Promise<void>
}
export function createStatusReporter(chatId: number, initialMsgId?: number | null): StatusReporter {
let msgId: number | null = initialMsgId ?? null
export function createStatusReporter(chatId: number): StatusReporter {
let msgId: number | null = null
async function deleteCurrent(): Promise<void> {
if (msgId) {

View File

@@ -1,13 +1,16 @@
import bot from '@bot'
import prisma from '@prisma'
import { createLogger } from './logger.js'
import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js'
// import * as process from 'node:process'
import './globals.js'
const log = createLogger('index')
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason)
console.error('[unhandledRejection]', msg)
log.error('[unhandledRejection]', reason)
})
import { registerHHCommands, triggerHHStart } from './hh/bot-commands.js'
// console.log('hi') //PWDEBUG=1
registerHHCommands()
bot.onText(/\/start/, async (msg) => {
@@ -30,11 +33,11 @@ bot.onText(/\/start/, async (msg) => {
if (!existingUser) {
await bot.sendMessage(
chatId,
`👋 Привет, ${msg.from?.first_name ?? 'друг'}!\n\nЭто бот для авто-откликов на hh.ru.\nНачни с логина — нажми 🔑 Логин.`,
`👋 Привет, ${msg.from?.first_name ?? 'друг'}!\n\nЭто бот для авто-откликов на hh.ru.\nНачни с логина — нажми 🔑 Войти.`,
)
}
await triggerHHStart(chatId)
})
console.log('Bot started 🚀')
log.ok('Bot started 🚀')

84
src/logger.ts Normal file
View File

@@ -0,0 +1,84 @@
const RESET = '\x1B[0m'
const BOLD = '\x1B[1m'
const DIM = '\x1B[2m'
const colors = {
gray: '\x1B[97m',
green: '\x1B[92m',
yellow: '\x1B[93m',
red: '\x1B[91m',
cyan: '\x1B[96m',
magenta: '\x1B[95m',
blue: '\x1B[94m',
white: '\x1B[97m',
}
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 dd = String(now.getDate()).padStart(2, '0')
const mo = String(now.getMonth() + 1).padStart(2, '0')
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
return `${DIM}${colors.gray}${dd}.${mo} ${hh}:${mm}${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<typeof createLogger>

View File

@@ -2,6 +2,9 @@ import process from 'node:process'
import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk'
// import Anthropic from '@anthropic-ai/sdk'
import OpenAI from 'openai'
import { createLogger } from './logger.js'
const log = createLogger('llm')
// export const claude = new Anthropic({
// apiKey: process.env.ANTHROPIC_API_KEY,
@@ -55,12 +58,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,28 +88,53 @@ export async function askLLM(userMessage: string) {
export async function createMessage(resume: string, message: string, prompt?: string) {
const client = await getClient()
log.debug('client.instance:', !!client.instance)
const session = await client.session.create({ body: { title: 'Cover letter' } })
const sessionId = session.data!.id
const finalPromt = 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
// Задаём роль без ответа
log.debug('sessionId:', sessionId)
const finalPromt = prompt || 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
const resumePreview = resume.slice(0, 200).replace(/\n/g, ' ')
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 },
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, ' ')
log.divider('Prompt 2 — vacancy')
log.llm(`📝 vacancy → ожидаю ответ…`)
log.llm(`vacancy: ${vacancyPreview}`)
// ${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')
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) {
log.error('Session cleanup error:', (e as Error).message)
}
return textPart?.text ?? null
}