mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-06-08 18:04:57 +00:00
Compare commits
44 Commits
5d19fa1cbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162abdfc0a | ||
|
|
33f67279c1 | ||
|
|
0378b57a9d | ||
|
|
4238058583 | ||
|
|
79ae47de71 | ||
|
|
199e58b251 | ||
|
|
a52d7a1706 | ||
|
|
ed87b2c642 | ||
|
|
d883b17bd1 | ||
|
|
0c18846cbb | ||
|
|
024b8f41b8 | ||
|
|
c67fcfe4c6 | ||
|
|
9434eeebfe | ||
|
|
c10f8282fc | ||
|
|
aded3fff7e | ||
|
|
6acc8d0adb | ||
|
|
810952a5c7 | ||
|
|
7b8d6350b8 | ||
|
|
956551e30e | ||
|
|
124302c661 | ||
|
|
675a14bba0 | ||
|
|
eb898f5604 | ||
|
|
e50eb58799 | ||
|
|
43cc28f1cf | ||
|
|
dffb59b79e | ||
|
|
a9635be640 | ||
|
|
6ae9b32e87 | ||
|
|
7fd71f27c8 | ||
|
|
a2b3539f35 | ||
|
|
1273eff7b7 | ||
|
|
ce07d962d7 | ||
|
|
01d014ff54 | ||
|
|
16924d5f6a | ||
|
|
0c9f52a349 | ||
|
|
49d711d29b | ||
|
|
6b2b2589ac | ||
|
|
66733c0f75 | ||
|
|
2407b166b5 | ||
|
|
604c77a04d | ||
|
|
c173845910 | ||
|
|
a9de783891 | ||
|
|
00d0a8d832 | ||
|
|
20a5f506da | ||
|
|
4761214ec1 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
prisma/migrations/*.db
|
||||
.idea
|
||||
.vscode
|
||||
coverage
|
||||
*.log
|
||||
.cache
|
||||
.tmp
|
||||
46
.gitea/workflows/deploy.yml
Normal file
46
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Keyscan
|
||||
run: |
|
||||
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
ssh-strict: false
|
||||
persist-credentials: false
|
||||
|
||||
# - name: Backup DB
|
||||
# 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 .
|
||||
|
||||
|
||||
- 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/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
|
||||
BIN
.yarn/install-state.gz
vendored
BIN
.yarn/install-state.gz
vendored
Binary file not shown.
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# ── Stage 1: build ─────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
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
|
||||
|
||||
RUN yarn prisma generate
|
||||
|
||||
# ── Stage 2: runtime ───────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 ./
|
||||
|
||||
RUN mkdir -p /data
|
||||
ENV DATABASE_URL="file:/data/dev.db"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npx tsx src/index.ts"]
|
||||
312
README.md
312
README.md
@@ -1,111 +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
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```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 бота
|
||||
|
||||
**Главное меню**
|
||||
| Кнопка | Действие |
|
||||
|---|---|
|
||||
| `🚀 Откликнуться` | Запустить поиск и отклики |
|
||||
| `⚙️ Настройки` | Открыть меню настроек |
|
||||
| `ℹ️ Информация` | Открыть меню информации |
|
||||
|
||||
**Настройки**
|
||||
| Кнопка | Действие |
|
||||
|---|---|
|
||||
| `🔢 Макс. откликов` | Лимит откликов за сессию (1–50) |
|
||||
| `🔍 Изменить запрос` | Поисковый запрос на hh.ru |
|
||||
| `⏰ Авто` | Вкл/выкл ежедневный cron (пн–пт 10:00) |
|
||||
| `📄 Выбрать резюме` | Загрузить список резюме с hh.ru |
|
||||
| `📝 Промт` | Настроить системный промпт для AI |
|
||||
| `🔑 Войти на hh.ru` | Авторизация (email или телефон + OTP) |
|
||||
|
||||
**Информация**
|
||||
| Кнопка | Действие |
|
||||
|---|---|
|
||||
| `⚙️ Статус` | Текущие настройки и статус авторизации |
|
||||
| `📋 Моё резюме` | Просмотр сохранённого резюме |
|
||||
| `🚫 Проблемные вакансии` | Вакансии с анкетой (бот не может откликнуться) |
|
||||
|
||||
BIN
data/dev.db
Normal file
BIN
data/dev.db
Normal file
Binary file not shown.
@@ -4,11 +4,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"build": "tsc && tsc-alias",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"db-view": "yarn prisma studio",
|
||||
"db:deploy": "npx prisma migrate deploy && npx prisma generate",
|
||||
"db-migrate": "npx prisma migrate dev",
|
||||
"db-deploy": "npx prisma migrate deploy",
|
||||
"db-migrate-server": "git pull npx prisma migrate deploy",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/node-telegram-bot-api": "^0.64.14",
|
||||
"eslint": "^9.18.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
19
prisma/migrations/20260528201931_promt_update/migration.sql
Normal file
19
prisma/migrations/20260528201931_promt_update/migration.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"telegramId" BIGINT NOT NULL,
|
||||
"username" TEXT,
|
||||
"firstName" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"session" TEXT,
|
||||
"hhEmail" TEXT,
|
||||
"prompt" TEXT NOT NULL DEFAULT 'Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.'
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "firstName", "hhEmail", "id", "prompt", "session", "telegramId", "username") SELECT "createdAt", "firstName", "hhEmail", "id", "prompt", "session", "telegramId", "username" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "hhPhone" TEXT;
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
|
||||
@@ -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
5
src/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const config = {
|
||||
// Maximum number of Playwright browser instances running simultaneously.
|
||||
// Each instance uses ~100-200 MB RAM. Increase only if server can handle it.
|
||||
maxConcurrentBrowsers: 3,
|
||||
}
|
||||
7
src/global.d.ts
vendored
Normal file
7
src/global.d.ts
vendored
Normal 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
3
src/globals.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from './logger.js'
|
||||
|
||||
;(globalThis as any).createLogger = createLogger
|
||||
@@ -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, 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 bot.editMessageText('🤖 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 bot.editMessageText('📄 Выбери резюме:', {
|
||||
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 bot.editMessageText(
|
||||
`📋 <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\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 bot.editMessageText(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 bot.editMessageText('🔄 Загружаю список резюме...', {
|
||||
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 bot.editMessageText('🔄 Сохраняю резюме...', {
|
||||
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 bot.editMessageText('🔄 Сохраняю резюме...', {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
59
src/hh/handlers/apply.ts
Normal 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
138
src/hh/handlers/auth.ts
Normal 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\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\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
6
src/hh/handlers/debug.ts
Normal 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
37
src/hh/handlers/info.ts
Normal 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: [] },
|
||||
})
|
||||
}
|
||||
119
src/hh/handlers/onboarding.ts
Normal file
119
src/hh/handlers/onboarding.ts
Normal 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\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\nДополнительные параметры (запрос, регион, слова-исключения) доступны в разделе <b>Фильтры</b>.`,
|
||||
{ parse_mode: 'HTML', reply_markup: MAIN_REPLY_KEYBOARD },
|
||||
)
|
||||
}
|
||||
108
src/hh/handlers/resume.ts
Normal file
108
src/hh/handlers/resume.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
83
src/hh/handlers/settings.ts
Normal file
83
src/hh/handlers/settings.ts
Normal 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Введи новое количество откликов (1–50) или оставь текущее:`,
|
||||
{
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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
50
src/hh/state.ts
Normal 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)!
|
||||
}
|
||||
118
src/hh/ui.ts
118
src/hh/ui.ts
@@ -1,47 +1,103 @@
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export async function showResult(chatId: number, messageId: number, text: string): Promise<void> {
|
||||
await bot.editMessageText(text, {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
reply_markup: BACK_MARKUP,
|
||||
export async function safeEdit(
|
||||
text: string,
|
||||
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')
|
||||
|| e.message.includes('message to edit not found')
|
||||
)) {
|
||||
return
|
||||
}
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,8 +107,8 @@ export interface StatusReporter {
|
||||
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) {
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,8 +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) => {
|
||||
log.error('[unhandledRejection]', reason)
|
||||
})
|
||||
// console.log('hi') //PWDEBUG=1
|
||||
registerHHCommands()
|
||||
|
||||
bot.onText(/\/start/, async (msg) => {
|
||||
@@ -25,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
84
src/logger.ts
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@bot": ["src/bot-singleton"],
|
||||
@@ -11,6 +12,7 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
|
||||
300
yarn.lock
300
yarn.lock
@@ -670,6 +670,33 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nodelib/fs.scandir@npm:2.1.5":
|
||||
version: 2.1.5
|
||||
resolution: "@nodelib/fs.scandir@npm:2.1.5"
|
||||
dependencies:
|
||||
"@nodelib/fs.stat": "npm:2.0.5"
|
||||
run-parallel: "npm:^1.1.9"
|
||||
checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2":
|
||||
version: 2.0.5
|
||||
resolution: "@nodelib/fs.stat@npm:2.0.5"
|
||||
checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nodelib/fs.walk@npm:^1.2.3":
|
||||
version: 1.2.8
|
||||
resolution: "@nodelib/fs.walk@npm:1.2.8"
|
||||
dependencies:
|
||||
"@nodelib/fs.scandir": "npm:2.1.5"
|
||||
fastq: "npm:^1.6.0"
|
||||
checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@opencode-ai/sdk@npm:^1.15.10":
|
||||
version: 1.15.10
|
||||
resolution: "@opencode-ai/sdk@npm:1.15.10"
|
||||
@@ -1294,6 +1321,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"anymatch@npm:~3.1.2":
|
||||
version: 3.1.3
|
||||
resolution: "anymatch@npm:3.1.3"
|
||||
dependencies:
|
||||
normalize-path: "npm:^3.0.0"
|
||||
picomatch: "npm:^2.0.4"
|
||||
checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"are-docs-informative@npm:^0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "are-docs-informative@npm:0.0.2"
|
||||
@@ -1332,6 +1369,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"array-union@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "array-union@npm:2.1.0"
|
||||
checksum: 10c0/429897e68110374f39b771ec47a7161fc6a8fc33e196857c0a396dc75df0b5f65e4d046674db764330b6bb66b39ef48dd7c53b6a2ee75cfb0681e0c1a7033962
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"array.prototype.findindex@npm:^2.0.2":
|
||||
version: 2.2.4
|
||||
resolution: "array.prototype.findindex@npm:2.2.4"
|
||||
@@ -1453,6 +1497,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"binary-extensions@npm:^2.0.0":
|
||||
version: 2.3.0
|
||||
resolution: "binary-extensions@npm:2.3.0"
|
||||
checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bl@npm:^1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "bl@npm:1.2.3"
|
||||
@@ -1516,6 +1567,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"braces@npm:^3.0.3, braces@npm:~3.0.2":
|
||||
version: 3.0.3
|
||||
resolution: "braces@npm:3.0.3"
|
||||
dependencies:
|
||||
fill-range: "npm:^7.1.1"
|
||||
checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"browserslist@npm:^4.28.1":
|
||||
version: 4.28.2
|
||||
resolution: "browserslist@npm:4.28.2"
|
||||
@@ -1647,6 +1707,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chokidar@npm:^3.5.3":
|
||||
version: 3.6.0
|
||||
resolution: "chokidar@npm:3.6.0"
|
||||
dependencies:
|
||||
anymatch: "npm:~3.1.2"
|
||||
braces: "npm:~3.0.2"
|
||||
fsevents: "npm:~2.3.2"
|
||||
glob-parent: "npm:~5.1.2"
|
||||
is-binary-path: "npm:~2.1.0"
|
||||
is-glob: "npm:~4.0.1"
|
||||
normalize-path: "npm:~3.0.0"
|
||||
readdirp: "npm:~3.6.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chokidar@npm:^4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "chokidar@npm:4.0.3"
|
||||
@@ -1738,6 +1817,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"commander@npm:^9.0.0":
|
||||
version: 9.5.0
|
||||
resolution: "commander@npm:9.5.0"
|
||||
checksum: 10c0/5f7784fbda2aaec39e89eb46f06a999e00224b3763dc65976e05929ec486e174fe9aac2655f03ba6a5e83875bd173be5283dc19309b7c65954701c02025b3c1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"comment-parser@npm:1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "comment-parser@npm:1.4.1"
|
||||
@@ -2082,6 +2168,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dir-glob@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "dir-glob@npm:3.0.1"
|
||||
dependencies:
|
||||
path-type: "npm:^4.0.0"
|
||||
checksum: 10c0/dcac00920a4d503e38bb64001acb19df4efc14536ada475725e12f52c16777afdee4db827f55f13a908ee7efc0cb282e2e3dbaeeb98c0993dd93d1802d3bf00c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^16.6.1":
|
||||
version: 16.6.1
|
||||
resolution: "dotenv@npm:16.6.1"
|
||||
@@ -3021,6 +3116,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-glob@npm:^3.2.9":
|
||||
version: 3.3.3
|
||||
resolution: "fast-glob@npm:3.3.3"
|
||||
dependencies:
|
||||
"@nodelib/fs.stat": "npm:^2.0.2"
|
||||
"@nodelib/fs.walk": "npm:^1.2.3"
|
||||
glob-parent: "npm:^5.1.2"
|
||||
merge2: "npm:^1.3.0"
|
||||
micromatch: "npm:^4.0.8"
|
||||
checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-json-stable-stringify@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "fast-json-stable-stringify@npm:2.1.0"
|
||||
@@ -3035,6 +3143,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastq@npm:^1.6.0":
|
||||
version: 1.20.1
|
||||
resolution: "fastq@npm:1.20.1"
|
||||
dependencies:
|
||||
reusify: "npm:^1.0.4"
|
||||
checksum: 10c0/e5dd725884decb1f11e5c822221d76136f239d0236f176fab80b7b8f9e7619ae57e6b4e5b73defc21e6b9ef99437ee7b545cff8e6c2c337819633712fa9d352e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fault@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "fault@npm:2.0.1"
|
||||
@@ -3072,6 +3189,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fill-range@npm:^7.1.1":
|
||||
version: 7.1.1
|
||||
resolution: "fill-range@npm:7.1.1"
|
||||
dependencies:
|
||||
to-regex-range: "npm:^5.0.1"
|
||||
checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"finalhandler@npm:~1.3.1":
|
||||
version: 1.3.2
|
||||
resolution: "finalhandler@npm:1.3.2"
|
||||
@@ -3198,7 +3324,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@npm:~2.3.3":
|
||||
"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
|
||||
version: 2.3.3
|
||||
resolution: "fsevents@npm:2.3.3"
|
||||
dependencies:
|
||||
@@ -3217,7 +3343,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
|
||||
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
|
||||
version: 2.3.3
|
||||
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
|
||||
dependencies:
|
||||
@@ -3310,7 +3436,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5, get-tsconfig@npm:^4.8.1":
|
||||
"get-tsconfig@npm:^4.10.0, get-tsconfig@npm:^4.10.1, get-tsconfig@npm:^4.7.5, get-tsconfig@npm:^4.8.1":
|
||||
version: 4.14.0
|
||||
resolution: "get-tsconfig@npm:4.14.0"
|
||||
dependencies:
|
||||
@@ -3351,6 +3477,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
|
||||
version: 5.1.2
|
||||
resolution: "glob-parent@npm:5.1.2"
|
||||
dependencies:
|
||||
is-glob: "npm:^4.0.1"
|
||||
checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob-parent@npm:^6.0.2":
|
||||
version: 6.0.2
|
||||
resolution: "glob-parent@npm:6.0.2"
|
||||
@@ -3393,6 +3528,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globby@npm:^11.0.4":
|
||||
version: 11.1.0
|
||||
resolution: "globby@npm:11.1.0"
|
||||
dependencies:
|
||||
array-union: "npm:^2.1.0"
|
||||
dir-glob: "npm:^3.0.1"
|
||||
fast-glob: "npm:^3.2.9"
|
||||
ignore: "npm:^5.2.0"
|
||||
merge2: "npm:^1.4.1"
|
||||
slash: "npm:^3.0.0"
|
||||
checksum: 10c0/b39511b4afe4bd8a7aead3a27c4ade2b9968649abab0a6c28b1a90141b96ca68ca5db1302f7c7bd29eab66bf51e13916b8e0a3d0ac08f75e1e84a39b35691189
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globrex@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "globrex@npm:0.1.2"
|
||||
@@ -3662,6 +3811,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-binary-path@npm:~2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "is-binary-path@npm:2.1.0"
|
||||
dependencies:
|
||||
binary-extensions: "npm:^2.0.0"
|
||||
checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-boolean-object@npm:^1.2.1":
|
||||
version: 1.2.2
|
||||
resolution: "is-boolean-object@npm:1.2.2"
|
||||
@@ -3754,7 +3912,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3":
|
||||
"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1":
|
||||
version: 4.0.3
|
||||
resolution: "is-glob@npm:4.0.3"
|
||||
dependencies:
|
||||
@@ -3787,6 +3945,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-number@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "is-number@npm:7.0.0"
|
||||
checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-regex@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "is-regex@npm:1.2.1"
|
||||
@@ -4329,6 +4494,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"merge2@npm:^1.3.0, merge2@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "merge2@npm:1.4.1"
|
||||
checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"methods@npm:~1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "methods@npm:1.1.2"
|
||||
@@ -4677,6 +4849,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromatch@npm:^4.0.8":
|
||||
version: 4.0.8
|
||||
resolution: "micromatch@npm:4.0.8"
|
||||
dependencies:
|
||||
braces: "npm:^3.0.3"
|
||||
picomatch: "npm:^2.3.1"
|
||||
checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime-db@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "mime-db@npm:1.52.0"
|
||||
@@ -4769,6 +4951,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mylas@npm:^2.1.9":
|
||||
version: 2.1.14
|
||||
resolution: "mylas@npm:2.1.14"
|
||||
checksum: 10c0/2f30cee712c497a8f5c2a7218b6644efd67babf9fa7f8442f8536e2c95e4fded44b7117aea61cb1616c8ce0cdcfd9de329d6fa16d81818141267942b4eadb711
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"napi-postinstall@npm:^0.3.0":
|
||||
version: 0.3.4
|
||||
resolution: "napi-postinstall@npm:0.3.4"
|
||||
@@ -4889,6 +5078,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "normalize-path@npm:3.0.0"
|
||||
checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nth-check@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "nth-check@npm:2.1.1"
|
||||
@@ -5025,6 +5221,7 @@ __metadata:
|
||||
playwright: "npm:^1.59.1"
|
||||
prisma: "npm:6"
|
||||
ts-node: "npm:^10.9.2"
|
||||
tsc-alias: "npm:^1.8.17"
|
||||
tsx: "npm:^4.21.0"
|
||||
typescript: "npm:^6.0.3"
|
||||
languageName: unknown
|
||||
@@ -5170,6 +5367,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-type@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "path-type@npm:4.0.0"
|
||||
checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pathe@npm:^2.0.0, pathe@npm:^2.0.1, pathe@npm:^2.0.3":
|
||||
version: 2.0.3
|
||||
resolution: "pathe@npm:2.0.3"
|
||||
@@ -5198,6 +5402,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1":
|
||||
version: 2.3.2
|
||||
resolution: "picomatch@npm:2.3.2"
|
||||
checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "picomatch@npm:4.0.4"
|
||||
@@ -5251,6 +5462,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"plimit-lit@npm:^1.2.6":
|
||||
version: 1.6.1
|
||||
resolution: "plimit-lit@npm:1.6.1"
|
||||
dependencies:
|
||||
queue-lit: "npm:^1.5.1"
|
||||
checksum: 10c0/af5d351bb55afe1eaa84b27c2b329699e150e4cf70464f3d474f5eabe9bdb9f48ed378ada1498d3b893f68ee7da2423ba6d9a4d88b1429d3b0aea22afcf5292b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pluralize@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "pluralize@npm:8.0.0"
|
||||
@@ -5388,6 +5608,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"queue-lit@npm:^1.5.1":
|
||||
version: 1.5.2
|
||||
resolution: "queue-lit@npm:1.5.2"
|
||||
checksum: 10c0/8aa838b2c939aeaa6cd272b5b6b172379a3fa1d9193b2a3e687643c68c0efee3cd3493af4f1f8a11ff79b8207e4d00cc5d0b072f6e4bbeaaa27ee01f567ec4ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"queue-microtask@npm:^1.2.2":
|
||||
version: 1.2.3
|
||||
resolution: "queue-microtask@npm:1.2.3"
|
||||
checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"radix3@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "radix3@npm:1.1.2"
|
||||
@@ -5469,6 +5703,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readdirp@npm:~3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "readdirp@npm:3.6.0"
|
||||
dependencies:
|
||||
picomatch: "npm:^2.2.1"
|
||||
checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "redis-errors@npm:1.2.0"
|
||||
@@ -5621,6 +5864,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"reusify@npm:^1.0.4":
|
||||
version: 1.1.0
|
||||
resolution: "reusify@npm:1.1.0"
|
||||
checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"run-parallel@npm:^1.1.9":
|
||||
version: 1.2.0
|
||||
resolution: "run-parallel@npm:1.2.0"
|
||||
dependencies:
|
||||
queue-microtask: "npm:^1.2.2"
|
||||
checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-array-concat@npm:^1.1.3":
|
||||
version: 1.1.4
|
||||
resolution: "safe-array-concat@npm:1.1.4"
|
||||
@@ -5853,6 +6112,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"slash@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "slash@npm:3.0.0"
|
||||
checksum: 10c0/e18488c6a42bdfd4ac5be85b2ced3ccd0224773baae6ad42cfbb9ec74fc07f9fa8396bd35ee638084ead7a2a0818eb5e7151111544d4731ce843019dab4be47b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spdx-correct@npm:^3.0.0":
|
||||
version: 3.2.0
|
||||
resolution: "spdx-correct@npm:3.2.0"
|
||||
@@ -6119,6 +6385,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-regex-range@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "to-regex-range@npm:5.0.1"
|
||||
dependencies:
|
||||
is-number: "npm:^7.0.0"
|
||||
checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"toidentifier@npm:~1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "toidentifier@npm:1.0.1"
|
||||
@@ -6214,6 +6489,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tsc-alias@npm:^1.8.17":
|
||||
version: 1.8.17
|
||||
resolution: "tsc-alias@npm:1.8.17"
|
||||
dependencies:
|
||||
chokidar: "npm:^3.5.3"
|
||||
commander: "npm:^9.0.0"
|
||||
get-tsconfig: "npm:^4.10.0"
|
||||
globby: "npm:^11.0.4"
|
||||
mylas: "npm:^2.1.9"
|
||||
normalize-path: "npm:^3.0.0"
|
||||
plimit-lit: "npm:^1.2.6"
|
||||
bin:
|
||||
tsc-alias: dist/bin/index.js
|
||||
checksum: 10c0/024301d25e48917da2b11bd92683fdf62c9c9603eb711937f0aa8e24df0d988290a4f5e94ac91b77601ea36335bf4ab5637944c47755df82a2a0cf95720d5c0f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tslib@npm:^2.4.0":
|
||||
version: 2.8.1
|
||||
resolution: "tslib@npm:2.8.1"
|
||||
|
||||
Reference in New Issue
Block a user