Compare commits

...
8 Commits
Author SHA1 Message Date
apuc 3179bac026 fix: письмо больше не попадает в вопрос скрининга — убран опасный fallback 'textarea' при наличии task_*, надёжный клик по переключателю письма, проверка ошибки валидации после отправки 2026-09-22 12:43:04 +03:00
apuc 6b2c7a0f09 fix: сброс статуса «Отправляю...» при ошибке отправки со скриншотом — модалка не висела с устаревшим статусом 2026-09-22 11:55:59 +03:00
apuc 06c142b1ea fix: браузер закрывается на ВСЕХ путях (try/finally в open_response_page, кроме ручного режима; confirm_send закрывает при ошибке); статус меняется при ok=true даже без скриншота 2026-09-22 11:46:50 +03:00
apuc 8ca070ed70 fix: LLM иногда возвращает пустой текст — raise при пустом content, ретрай в generate_response_with_retry, фронтенд показывает ошибку вместо молчания 2026-09-22 11:37:38 +03:00
apuc d757d2f8c7 fix: hh.ru прячет поле письма за переключателем — клик по vacancy-response-letter-toggle, новый селектор письма и кнопки отправки; фронтенд больше не показывает успех при ok=false 2026-09-22 11:11:13 +03:00
apuc bd8d865798 fix: вторая модалка подтверждения hh.ru — повторный клик «Отправить отклик» после «Отправить» 2026-09-16 17:28:31 +03:00
apuc bb105a08b1 README: порт 18091, регистрация, одно-кликовая отправка, переменные окружения 2026-09-16 14:10:42 +03:00
apuc 0d2be8f873 fix: закрывать браузер в finally — после отправки hh.ru закрывает модалку, wait_for_timeout падал с TargetClosedError и _close_context пропускался 2026-09-16 13:40:43 +03:00
5 changed files with 236 additions and 82 deletions

No files matched your search

+38 -8
View File
@@ -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
View File
@@ -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, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
+4 -1
View File
@@ -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
View File
@@ -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 || 'Не удалось отправить отклик');
} }
+3 -3
View File
@@ -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())