Compare commits

...
6 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
4 changed files with 128 additions and 13 deletions

No files matched your search

+100 -6
View File
@@ -27,16 +27,31 @@ RESPONSE_BUTTONS = [
'[data-qa="vacancy-response-link"]',
'button:has-text("Откликнуться")',
]
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо / Добавить»:
# до клика по нему на форме есть только textarea вопроса скрининга (task_*).
# ВАЖНО: запасной селектор 'textarea' НЕ используется, если есть вопросы скрининга —
# иначе текст письма попадёт в первый вопрос вместо поля письма.
LETTER_TOGGLE = '[data-qa="vacancy-response-letter-toggle"]'
MESSAGE_FIELDS = [
'textarea[data-qa="vacancy-response-popup-form-letter-input"]',
'textarea[data-qa="vacancy-response-letter"]',
'textarea[data-qa="vacancy-response-popup-form"] textarea',
'textarea',
]
SUBMIT_BUTTONS = [
'[data-qa="vacancy-response-submit-popup"]',
'[data-qa="vacancy-response-submit"]',
'[data-qa="vacancy-response-popup-form"] button[type="submit"]',
'button:has-text("Отправить")',
]
# Кнопки подтверждения во ВТОРОЙ модалке hh.ru: после клика «Отправить»
# иногда появляется модалка «Отклик будет отправлен» с кнопкой
# «Отправить отклик» — её нужно нажать ещё раз.
CONFIRM_BUTTONS = [
'button:has-text("Отправить отклик")',
'button:has-text("Подтвердить")',
'button:has-text("Да, отправить")',
'[data-qa="vacancy-response-confirm"]',
]
# Живые браузерные контексты и страницы: {(user, account): ...}
# Доступны ТОЛЬКО из браузерного потока.
@@ -199,11 +214,33 @@ def _save_screenshot(page, screenshot_dir, vid):
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,
resume_id=None, screenshot_dir=None, submit=False):
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
Возвращает (ok, note, screenshot_path)."""
Возвращает (ok, note, screenshot_path).
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
там он нужен для confirm_send."""
key = (user, account)
# Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None)
@@ -215,6 +252,8 @@ def open_response_page(account_dir, user, account, vacancy, message,
page = _new_page(account_dir, user, account)
_pages[key] = page
keep_open = False
try:
page.goto(vacancy["url"], timeout=45000)
page.wait_for_timeout(2500)
@@ -234,7 +273,18 @@ def open_response_page(account_dir, user, account, vacancy, message,
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
# Ждём модалку
page.wait_for_timeout(2000)
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:
@@ -258,6 +308,19 @@ def open_response_page(account_dir, user, account, vacancy, message,
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
@@ -277,20 +340,44 @@ def open_response_page(account_dir, user, account, vacancy, message,
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 может закрыть модалку/перезагрузить страницу —
# ловим любые ошибки, но браузер закрываем ВСЕГДА (finally).
# ловим любые ошибки, но отклик уже отправлен.
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
finally:
_close_context(key)
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):
@@ -304,6 +391,11 @@ def confirm_send(user, account, vid):
btn = page.locator(sel).first
if btn.is_visible(timeout=3000):
btn.click()
try:
# Вторая модалка подтверждения (если появилась)
_click_second_confirm(page)
except Exception:
pass
try:
page.wait_for_timeout(1500)
except Exception:
@@ -314,6 +406,8 @@ def confirm_send(user, account, vid):
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
except Exception:
continue
# Кнопка не найдена — браузер больше не нужен, закрываем
_close_context(key)
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]}")
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):
+22 -4
View File
@@ -656,6 +656,8 @@ async function openSendModal(id) {
renderTexts();
} else if (data.errors && data.errors[id]) {
document.getElementById('send-modal-note').textContent = 'Ошибка: ' + data.errors[id];
} else {
document.getElementById('send-modal-note').textContent = 'Не удалось сгенерировать текст — попробуйте ещё раз';
}
} catch (e) {
document.getElementById('send-loading').classList.add('hidden');
@@ -677,14 +679,24 @@ async function sendFromModal() {
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
});
if (data.screenshot) {
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
if (data.ok) {
// Отклик отправлен — статус меняем всегда, даже если скриншота нет
sentTexts.add(sendModalId);
const v = vacancies.find(x => x.id === sendModalId);
if (v) v.responded = true;
renderVacancies();
renderTexts();
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 {
note.textContent = data.note || 'Не удалось отправить отклик';
btn.disabled = false;
@@ -737,13 +749,19 @@ async function sendVacancy(id) {
method: 'POST', body: JSON.stringify({ id, message }),
});
if (note) note.textContent = data.note || '';
if (data.screenshot) {
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
if (data.ok) {
sentTexts.add(id);
const v = vacancies.find(x => x.id === id);
if (v) v.responded = true;
renderVacancies();
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 {
alert(data.note || 'Не удалось отправить отклик');
}
+2 -2
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,
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
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -508,7 +508,7 @@ def api_generate(name):
details = fetch_vacancy_details(vid, proxies=get_proxies()) or {}
vacancy_full = {**vacancy, **details}
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", ""))
if limit is not None:
used += 1