Compare commits
8
Commits
fix/recent
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3179bac026 | ||
|
|
6b2c7a0f09 | ||
|
|
06c142b1ea | ||
|
|
8ca070ed70 | ||
|
|
d757d2f8c7 | ||
|
|
bd8d865798 | ||
|
|
bb105a08b1 | ||
|
|
0d2be8f873 |
No files matched your search
@@ -1,9 +1,9 @@
|
|||||||
# Автоотклик на hh.ru (полуавтоматический, безопасный)
|
# Автоотклик на hh.ru (полуавтоматический)
|
||||||
|
|
||||||
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
|
Скрипт ищет вакансии на hh.ru по ключевым словам, генерирует персонализированный
|
||||||
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
|
текст отклика через ИИ (на основе вашего резюме и описания вакансии), открывает
|
||||||
страницу в браузере и подставляет текст. **Кнопку «Отправить» всегда нажимаете вы** —
|
страницу в браузере, подставляет текст и резюме. Отправка — в один клик
|
||||||
это не нарушает правила hh.ru и не рискует аккаунтом.
|
(«Отправить отклик»), либо с проверкой текста перед отправкой.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -25,17 +25,30 @@ PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright .venv/bin
|
|||||||
.venv/bin/python webui.py
|
.venv/bin/python webui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Откройте **http://localhost:5000** — там можно:
|
Откройте **http://localhost:18091** (порт меняется через `PORT`) — там можно:
|
||||||
|
|
||||||
|
- регистрироваться и входить (открытая регистрация, отключается `ALLOW_REGISTRATION=0`)
|
||||||
- создавать аккаунты (имя, регион, поисковые запросы, стиль)
|
- создавать аккаунты (имя, регион, поисковые запросы, стиль)
|
||||||
- загружать резюме файлом (**txt, md, docx, pdf**)
|
- загружать резюме файлом (**txt, md, docx, pdf**)
|
||||||
- сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме
|
- сохранять **ключ сессии hh.ru** (cookies из браузера) — нужен для поиска и списка резюме
|
||||||
- выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика
|
- выбирать **резюме на hh.ru** — оно автоматически подставляется в модалке отклика
|
||||||
- искать вакансии и видеть их списком (название, компания, зарплата, опыт)
|
- искать вакансии и видеть их списком (название, компания, зарплата, опыт)
|
||||||
- генерировать тексты откликов через ИИ (редактируются перед отправкой)
|
- генерировать тексты откликов через ИИ (редактируются перед отправкой)
|
||||||
|
- отправлять отклик в один клик: кнопка «Отправить отклик» в колонке действий —
|
||||||
|
браузер открывает вакансию, вставляет текст, жмёт «Отправить» и закрывается
|
||||||
|
(скриншот результата показывается в модалке)
|
||||||
- открывать вакансию в браузере с уже вставленным текстом и выбранным резюме
|
- открывать вакансию в браузере с уже вставленным текстом и выбранным резюме
|
||||||
|
для ручной проверки перед отправкой
|
||||||
- отмечать отправленные — они больше не предлагаются
|
- отмечать отправленные — они больше не предлагаются
|
||||||
|
|
||||||
|
### Регистрация
|
||||||
|
|
||||||
|
При первом запуске откройте `/register` и создайте логин с паролем (минимум
|
||||||
|
6 символов) — вы сразу войдёте. Каждый пользователь работает со своими
|
||||||
|
аккаунтами и лимитом генераций. Открытая регистрация отключается
|
||||||
|
`ALLOW_REGISTRATION=0` (пользователей тогда создаёт администратор через
|
||||||
|
`manage_users.py`).
|
||||||
|
|
||||||
### Ключ сессии hh.ru
|
### Ключ сессии hh.ru
|
||||||
|
|
||||||
Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера:
|
Чтобы скрипт мог искать вакансии и видеть ваши резюме, нужны cookies из браузера:
|
||||||
@@ -89,13 +102,30 @@ accounts/
|
|||||||
1. Скрипт парсит страницу поиска hh.ru по вашим запросам
|
1. Скрипт парсит страницу поиска hh.ru по вашим запросам
|
||||||
2. Для каждой новой вакансии получает описание
|
2. Для каждой новой вакансии получает описание
|
||||||
3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль)
|
3. ИИ генерирует текст отклика (вакансия + ваше резюме + стиль)
|
||||||
4. Браузер открывает вакансию, жмёт «Откликнуться», вставляет текст
|
4. **Один клик**: кнопка «Отправить отклик» — браузер открывает вакансию,
|
||||||
5. **Вы проверяете текст и нажимаете «Отправить»**
|
жмёт «Откликнуться», вставляет текст, выбирает резюме, жмёт «Отправить»
|
||||||
6. Отмечаете отправленное — скрипт переходит к следующей
|
и автоматически закрывается (скриншот результата — в модалке)
|
||||||
|
5. **С проверкой**: «Открыть в браузере» — вы проверяете текст и отправляете
|
||||||
|
сами, либо жмёте «Отправить отклик» в модалке скриншота
|
||||||
|
6. Отправленные вакансии отмечаются и больше не предлагаются
|
||||||
|
|
||||||
|
## Переменные окружения (.env)
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `LLM_API_KEY` | — | Ключ API для генерации текстов откликов |
|
||||||
|
| `SESSION_SECRET` | `dev-secret-change-me` | Секрет сессий Flask (обязательно сменить) |
|
||||||
|
| `PORT` | `18091` | Порт веб-интерфейса |
|
||||||
|
| `HOST` | `127.0.0.1` | Адрес прослушивания |
|
||||||
|
| `ALLOW_REGISTRATION` | `1` | Открытая регистрация (`0` — выключить) |
|
||||||
|
| `REGISTER_LLM_LIMIT` | `50` | Лимит генераций для новых пользователей |
|
||||||
|
| `HH_HEADLESS` | `0` | `1` — браузер без окна (для сервера без дисплея) |
|
||||||
|
| `PROXY_URL` | — | Прокси для браузера: `http://LOGIN:PASS@HOST:PORT` |
|
||||||
|
|
||||||
## Важно
|
## Важно
|
||||||
|
|
||||||
- Скрипт не нарушает правила hh.ru: финальное действие всегда за человеком
|
- Отправка в один клик автоматически жмёт «Отправить» — используйте с осторожностью
|
||||||
|
и не чаще, чем позволяет hh.ru (паузы между страницами уже стоят)
|
||||||
- Не запускайте поиск слишком часто — между страницами есть паузы
|
- Не запускайте поиск слишком часто — между страницами есть паузы
|
||||||
- Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова
|
- Если hh.ru покажет капчу — скрипт остановится, решите капчу вручную и запустите снова
|
||||||
- `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`)
|
- `.env` содержит API-ключ — не коммитьте его в git (уже добавлен в `.gitignore`)
|
||||||
+169
-66
@@ -27,16 +27,31 @@ RESPONSE_BUTTONS = [
|
|||||||
'[data-qa="vacancy-response-link"]',
|
'[data-qa="vacancy-response-link"]',
|
||||||
'button:has-text("Откликнуться")',
|
'button:has-text("Откликнуться")',
|
||||||
]
|
]
|
||||||
|
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо / Добавить»:
|
||||||
|
# до клика по нему на форме есть только textarea вопроса скрининга (task_*).
|
||||||
|
# ВАЖНО: запасной селектор 'textarea' НЕ используется, если есть вопросы скрининга —
|
||||||
|
# иначе текст письма попадёт в первый вопрос вместо поля письма.
|
||||||
|
LETTER_TOGGLE = '[data-qa="vacancy-response-letter-toggle"]'
|
||||||
MESSAGE_FIELDS = [
|
MESSAGE_FIELDS = [
|
||||||
|
'textarea[data-qa="vacancy-response-popup-form-letter-input"]',
|
||||||
'textarea[data-qa="vacancy-response-letter"]',
|
'textarea[data-qa="vacancy-response-letter"]',
|
||||||
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
||||||
'textarea',
|
|
||||||
]
|
]
|
||||||
SUBMIT_BUTTONS = [
|
SUBMIT_BUTTONS = [
|
||||||
|
'[data-qa="vacancy-response-submit-popup"]',
|
||||||
'[data-qa="vacancy-response-submit"]',
|
'[data-qa="vacancy-response-submit"]',
|
||||||
'[data-qa="vacancy-response-popup-form"] button[type="submit"]',
|
'[data-qa="vacancy-response-popup-form"] button[type="submit"]',
|
||||||
'button:has-text("Отправить")',
|
'button:has-text("Отправить")',
|
||||||
]
|
]
|
||||||
|
# Кнопки подтверждения во ВТОРОЙ модалке hh.ru: после клика «Отправить»
|
||||||
|
# иногда появляется модалка «Отклик будет отправлен» с кнопкой
|
||||||
|
# «Отправить отклик» — её нужно нажать ещё раз.
|
||||||
|
CONFIRM_BUTTONS = [
|
||||||
|
'button:has-text("Отправить отклик")',
|
||||||
|
'button:has-text("Подтвердить")',
|
||||||
|
'button:has-text("Да, отправить")',
|
||||||
|
'[data-qa="vacancy-response-confirm"]',
|
||||||
|
]
|
||||||
|
|
||||||
# Живые браузерные контексты и страницы: {(user, account): ...}
|
# Живые браузерные контексты и страницы: {(user, account): ...}
|
||||||
# Доступны ТОЛЬКО из браузерного потока.
|
# Доступны ТОЛЬКО из браузерного потока.
|
||||||
@@ -199,11 +214,33 @@ def _save_screenshot(page, screenshot_dir, vid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _click_second_confirm(page):
|
||||||
|
"""После клика «Отправить» hh.ru иногда показывает вторую модалку
|
||||||
|
подтверждения («Отклик будет отправлен») — жмём и её.
|
||||||
|
Возвращает True, если вторая кнопка найдена и нажата."""
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
for sel in CONFIRM_BUTTONS:
|
||||||
|
try:
|
||||||
|
btn = page.locator(sel).first
|
||||||
|
if btn.is_visible(timeout=1500):
|
||||||
|
btn.click()
|
||||||
|
print(" ✓ Вторая модалка подтверждения — кнопка нажата")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def open_response_page(account_dir, user, account, vacancy, message,
|
def open_response_page(account_dir, user, account, vacancy, message,
|
||||||
resume_id=None, screenshot_dir=None, submit=False):
|
resume_id=None, screenshot_dir=None, submit=False):
|
||||||
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
|
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
|
||||||
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
|
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
|
||||||
Возвращает (ok, note, screenshot_path)."""
|
Возвращает (ok, note, screenshot_path).
|
||||||
|
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
|
||||||
|
там он нужен для confirm_send."""
|
||||||
key = (user, account)
|
key = (user, account)
|
||||||
# Закрываем предыдущую страницу этого аккаунта
|
# Закрываем предыдущую страницу этого аккаунта
|
||||||
prev = _pages.pop(key, None)
|
prev = _pages.pop(key, None)
|
||||||
@@ -215,77 +252,132 @@ def open_response_page(account_dir, user, account, vacancy, message,
|
|||||||
|
|
||||||
page = _new_page(account_dir, user, account)
|
page = _new_page(account_dir, user, account)
|
||||||
_pages[key] = page
|
_pages[key] = page
|
||||||
page.goto(vacancy["url"], timeout=45000)
|
keep_open = False
|
||||||
page.wait_for_timeout(2500)
|
try:
|
||||||
|
page.goto(vacancy["url"], timeout=45000)
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
# Кнопка «Откликнуться»
|
# Кнопка «Откликнуться»
|
||||||
clicked = False
|
clicked = False
|
||||||
for sel in RESPONSE_BUTTONS:
|
for sel in RESPONSE_BUTTONS:
|
||||||
try:
|
|
||||||
btn = page.locator(sel).first
|
|
||||||
if btn.is_visible(timeout=3000):
|
|
||||||
btn.click()
|
|
||||||
clicked = True
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if not clicked:
|
|
||||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
|
||||||
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
|
|
||||||
|
|
||||||
# Ждём модалку
|
|
||||||
page.wait_for_timeout(2000)
|
|
||||||
|
|
||||||
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
|
|
||||||
if resume_id:
|
|
||||||
try:
|
|
||||||
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
|
|
||||||
if radio.is_visible(timeout=3000):
|
|
||||||
radio.check(force=True)
|
|
||||||
print(f" ✓ Резюме {resume_id} выбрано")
|
|
||||||
except Exception:
|
|
||||||
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
|
|
||||||
|
|
||||||
# Вставляем текст
|
|
||||||
filled = False
|
|
||||||
for sel in MESSAGE_FIELDS:
|
|
||||||
try:
|
|
||||||
field = page.locator(sel).first
|
|
||||||
if field.is_visible(timeout=3000):
|
|
||||||
field.fill(message)
|
|
||||||
filled = True
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not filled:
|
|
||||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
|
||||||
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
|
|
||||||
|
|
||||||
# Одно-кликовый режим: сразу жмём «Отправить»
|
|
||||||
if submit:
|
|
||||||
submitted = False
|
|
||||||
for sel in SUBMIT_BUTTONS:
|
|
||||||
try:
|
try:
|
||||||
btn = page.locator(sel).first
|
btn = page.locator(sel).first
|
||||||
if btn.is_visible(timeout=3000):
|
if btn.is_visible(timeout=3000):
|
||||||
btn.click()
|
btn.click()
|
||||||
submitted = True
|
clicked = True
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if not submitted:
|
if not clicked:
|
||||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
|
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
|
||||||
# Ждём подтверждение и снимаем скриншот результата
|
|
||||||
page.wait_for_timeout(2500)
|
|
||||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
|
||||||
# Отклик отправлен — закрываем браузер автоматически
|
|
||||||
_close_context(key)
|
|
||||||
return True, "Отклик отправлен", shot
|
|
||||||
|
|
||||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
# Ждём модалку
|
||||||
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо» —
|
||||||
|
# раскрываем его, иначе заполнится textarea вопроса скрининга (task_*)
|
||||||
|
try:
|
||||||
|
toggle = page.locator(LETTER_TOGGLE).first
|
||||||
|
toggle.wait_for(state="visible", timeout=5000)
|
||||||
|
toggle.click()
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
print(" ✓ Переключатель «Сопроводительное письмо» раскрыт")
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠ Переключатель письма не найден — поле может отсутствовать")
|
||||||
|
|
||||||
|
# Выбор резюме (радио-кнопка name="resumeId" value="{hash}")
|
||||||
|
if resume_id:
|
||||||
|
try:
|
||||||
|
radio = page.locator(f'input[name="resumeId"][value="{resume_id}"]').first
|
||||||
|
if radio.is_visible(timeout=3000):
|
||||||
|
radio.check(force=True)
|
||||||
|
print(f" ✓ Резюме {resume_id} выбрано")
|
||||||
|
except Exception:
|
||||||
|
print(" ⚠ Не нашёл радио-кнопку резюме — выберите вручную")
|
||||||
|
|
||||||
|
# Вставляем текст
|
||||||
|
filled = False
|
||||||
|
for sel in MESSAGE_FIELDS:
|
||||||
|
try:
|
||||||
|
field = page.locator(sel).first
|
||||||
|
if field.is_visible(timeout=3000):
|
||||||
|
field.fill(message)
|
||||||
|
filled = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not filled:
|
||||||
|
# Запасной вариант: если вопросов скрининга НЕТ, единственная
|
||||||
|
# textarea на форме — это поле письма (старая версия формы).
|
||||||
|
# При наличии task_* НЕ заполняем — иначе письмо попадёт в вопрос.
|
||||||
|
try:
|
||||||
|
if page.locator('textarea[name^="task_"]').count() == 0:
|
||||||
|
field = page.locator('textarea').first
|
||||||
|
if field.is_visible(timeout=2000):
|
||||||
|
field.fill(message)
|
||||||
|
filled = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not filled:
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return False, "Не нашёл поле сообщения — проверьте скриншот", shot
|
||||||
|
|
||||||
|
# Одно-кликовый режим: сразу жмём «Отправить»
|
||||||
|
if submit:
|
||||||
|
submitted = False
|
||||||
|
for sel in SUBMIT_BUTTONS:
|
||||||
|
try:
|
||||||
|
btn = page.locator(sel).first
|
||||||
|
if btn.is_visible(timeout=3000):
|
||||||
|
btn.click()
|
||||||
|
submitted = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not submitted:
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
|
||||||
|
# hh.ru иногда показывает вторую модалку подтверждения — жмём и её
|
||||||
|
try:
|
||||||
|
_click_second_confirm(page)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Ждём подтверждение и снимаем скриншот результата.
|
||||||
|
# После отправки hh.ru может закрыть модалку/перезагрузить страницу —
|
||||||
|
# ловим любые ошибки, но отклик уже отправлен.
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
# Проверяем, не осталась ли форма открытой с ошибкой валидации
|
||||||
|
# (например, не заполнены обязательные вопросы скрининга)
|
||||||
|
form_open = page.locator('[data-qa="vacancy-response-popup-form"]').count()
|
||||||
|
err_visible = False
|
||||||
|
if form_open:
|
||||||
|
for sel in ['[data-qa="vacancy-response-error"]',
|
||||||
|
'[data-qa="vacancy-response-popup-form"] [class*="error"]']:
|
||||||
|
try:
|
||||||
|
if page.locator(sel).first.is_visible(timeout=1000):
|
||||||
|
err_visible = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
if form_open and err_visible:
|
||||||
|
return False, "hh.ru не принял отклик — проверьте скриншот (возможно, не заполнены обязательные вопросы)", shot
|
||||||
|
except Exception:
|
||||||
|
shot = None
|
||||||
|
return True, "Отклик отправлен", shot
|
||||||
|
|
||||||
|
# Ручной режим: браузер оставляем открытым для confirm_send
|
||||||
|
keep_open = True
|
||||||
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||||
|
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
||||||
|
finally:
|
||||||
|
# Браузер закрываем ВСЕГДА, кроме ручного режима (нужен для confirm_send)
|
||||||
|
if not keep_open:
|
||||||
|
_close_context(key)
|
||||||
|
|
||||||
|
|
||||||
def confirm_send(user, account, vid):
|
def confirm_send(user, account, vid):
|
||||||
@@ -299,12 +391,23 @@ def confirm_send(user, account, vid):
|
|||||||
btn = page.locator(sel).first
|
btn = page.locator(sel).first
|
||||||
if btn.is_visible(timeout=3000):
|
if btn.is_visible(timeout=3000):
|
||||||
btn.click()
|
btn.click()
|
||||||
page.wait_for_timeout(1500)
|
try:
|
||||||
# Отклик отправлен — закрываем браузер автоматически
|
# Вторая модалка подтверждения (если появилась)
|
||||||
_close_context(key)
|
_click_second_confirm(page)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Отклик отправлен — закрываем браузер автоматически
|
||||||
|
_close_context(key)
|
||||||
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
|
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
# Кнопка не найдена — браузер больше не нужен, закрываем
|
||||||
|
_close_context(key)
|
||||||
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
|
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ def generate_response(llm_config, vacancy, resume_text, style_hint=""):
|
|||||||
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
content = (data["choices"][0]["message"].get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError("LLM вернул пустой текст — попробуйте ещё раз")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
||||||
|
|||||||
+22
-4
@@ -656,6 +656,8 @@ async function openSendModal(id) {
|
|||||||
renderTexts();
|
renderTexts();
|
||||||
} else if (data.errors && data.errors[id]) {
|
} else if (data.errors && data.errors[id]) {
|
||||||
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
|
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
|
||||||
|
} else {
|
||||||
|
document.getElementById('send-modal-note').textContent = 'Не удалось сгенерировать текст — попробуйте ещё раз';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('send-loading').classList.add('hidden');
|
document.getElementById('send-loading').classList.add('hidden');
|
||||||
@@ -677,14 +679,24 @@ async function sendFromModal() {
|
|||||||
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
|
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
|
||||||
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
|
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
|
||||||
});
|
});
|
||||||
if (data.screenshot) {
|
if (data.ok) {
|
||||||
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
|
// Отклик отправлен — статус меняем всегда, даже если скриншота нет
|
||||||
sentTexts.add(sendModalId);
|
sentTexts.add(sendModalId);
|
||||||
const v = vacancies.find(x => x.id === sendModalId);
|
const v = vacancies.find(x => x.id === sendModalId);
|
||||||
if (v) v.responded = true;
|
if (v) v.responded = true;
|
||||||
renderVacancies();
|
renderVacancies();
|
||||||
renderTexts();
|
renderTexts();
|
||||||
closeSendModal();
|
closeSendModal();
|
||||||
|
if (data.screenshot) {
|
||||||
|
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||||
|
} else {
|
||||||
|
alert(data.note || 'Отклик отправлен');
|
||||||
|
}
|
||||||
|
} else if (data.screenshot) {
|
||||||
|
// Бэкенд вернул скриншот, но отклик НЕ отправлен (например, не нашёл поле)
|
||||||
|
note.textContent = data.note || 'Не удалось отправить отклик';
|
||||||
|
btn.disabled = false;
|
||||||
|
showShotModal(sendModalId, data.screenshot, data.note || 'Не удалось отправить отклик', false);
|
||||||
} else {
|
} else {
|
||||||
note.textContent = data.note || 'Не удалось отправить отклик';
|
note.textContent = data.note || 'Не удалось отправить отклик';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -737,13 +749,19 @@ async function sendVacancy(id) {
|
|||||||
method: 'POST', body: JSON.stringify({ id, message }),
|
method: 'POST', body: JSON.stringify({ id, message }),
|
||||||
});
|
});
|
||||||
if (note) note.textContent = data.note || '';
|
if (note) note.textContent = data.note || '';
|
||||||
if (data.screenshot) {
|
if (data.ok) {
|
||||||
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
|
|
||||||
sentTexts.add(id);
|
sentTexts.add(id);
|
||||||
const v = vacancies.find(x => x.id === id);
|
const v = vacancies.find(x => x.id === id);
|
||||||
if (v) v.responded = true;
|
if (v) v.responded = true;
|
||||||
renderVacancies();
|
renderVacancies();
|
||||||
renderTexts();
|
renderTexts();
|
||||||
|
if (data.screenshot) {
|
||||||
|
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||||
|
} else {
|
||||||
|
alert(data.note || 'Отклик отправлен');
|
||||||
|
}
|
||||||
|
} else if (data.screenshot) {
|
||||||
|
showShotModal(id, data.screenshot, data.note || 'Не удалось отправить отклик', false);
|
||||||
} else {
|
} else {
|
||||||
alert(data.note || 'Не удалось отправить отклик');
|
alert(data.note || 'Не удалось отправить отклик');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
|||||||
|
|
||||||
from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes,
|
from hh_search import (search_vacancies, fetch_vacancy_details, fetch_my_resumes,
|
||||||
parse_cookies, get_proxies)
|
parse_cookies, get_proxies)
|
||||||
from hh_llm import generate_response
|
from hh_llm import generate_response_with_retry
|
||||||
from hh_browser import browser_open, browser_send, browser_close
|
from hh_browser import browser_open, browser_send, browser_close
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -508,8 +508,8 @@ def api_generate(name):
|
|||||||
details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
|
details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
|
||||||
vacancy_full = {**vacancy, **details}
|
vacancy_full = {**vacancy, **details}
|
||||||
try:
|
try:
|
||||||
texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full,
|
texts[vid] = generate_response_with_retry(cfg.get("llm", {}), vacancy_full,
|
||||||
resume, cfg.get("style", ""))
|
resume, cfg.get("style", ""))
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
used += 1
|
used += 1
|
||||||
bump_llm_usage(current_user())
|
bump_llm_usage(current_user())
|
||||||
|
|||||||
Reference in new issue
Block a user