Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7619f28fe | ||
|
|
31fd316d51 | ||
|
|
3d5610ca79 |
No files matched your search
+70
-125
@@ -27,18 +27,12 @@ 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("Отправить")',
|
||||
@@ -238,9 +232,7 @@ def open_response_page(account_dir, user, account, vacancy, message,
|
||||
resume_id=None, screenshot_dir=None, submit=False):
|
||||
"""Открывает вакансию, жмёт «Откликнуться», выбирает резюме, вставляет текст.
|
||||
При submit=True дополнительно жмёт «Отправить» и снимает скриншот результата.
|
||||
Возвращает (ok, note, screenshot_path).
|
||||
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
|
||||
там он нужен для confirm_send."""
|
||||
Возвращает (ok, note, screenshot_path)."""
|
||||
key = (user, account)
|
||||
# Закрываем предыдущую страницу этого аккаунта
|
||||
prev = _pages.pop(key, None)
|
||||
@@ -252,132 +244,87 @@ 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)
|
||||
page.goto(vacancy["url"], timeout=45000)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# Кнопка «Откликнуться»
|
||||
clicked = False
|
||||
for sel in RESPONSE_BUTTONS:
|
||||
# Кнопка «Откликнуться»
|
||||
clicked = False
|
||||
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:
|
||||
btn = page.locator(sel).first
|
||||
if btn.is_visible(timeout=3000):
|
||||
btn.click()
|
||||
clicked = True
|
||||
submitted = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not clicked:
|
||||
if not submitted:
|
||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||
return False, "Не нашёл кнопку «Откликнуться» — проверьте скриншот", shot
|
||||
|
||||
# Ждём модалку
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# hh.ru прячет поле письма за переключателем «Сопроводительное письмо» —
|
||||
# раскрываем его, иначе заполнится textarea вопроса скрининга (task_*)
|
||||
return False, "Не нашёл кнопку «Отправить» — проверьте скриншот", shot
|
||||
# hh.ru иногда показывает вторую модалку подтверждения — жмём и её
|
||||
try:
|
||||
toggle = page.locator(LETTER_TOGGLE).first
|
||||
toggle.wait_for(state="visible", timeout=5000)
|
||||
toggle.click()
|
||||
page.wait_for_timeout(800)
|
||||
print(" ✓ Переключатель «Сопроводительное письмо» раскрыт")
|
||||
_click_second_confirm(page)
|
||||
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:
|
||||
pass
|
||||
# Ждём подтверждение и снимаем скриншот результата.
|
||||
# После отправки hh.ru может закрыть модалку/перезагрузить страницу —
|
||||
# ловим любые ошибки, но браузер закрываем ВСЕГДА (finally).
|
||||
try:
|
||||
page.wait_for_timeout(2500)
|
||||
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:
|
||||
except Exception:
|
||||
shot = None
|
||||
finally:
|
||||
_close_context(key)
|
||||
return True, "Отклик отправлен", shot
|
||||
|
||||
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
||||
return True, "Текст вставлен. Проверьте скриншот и нажмите «Отправить»", shot
|
||||
|
||||
|
||||
def confirm_send(user, account, vid):
|
||||
@@ -406,8 +353,6 @@ def confirm_send(user, account, vid):
|
||||
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
|
||||
except Exception:
|
||||
continue
|
||||
# Кнопка не найдена — браузер больше не нужен, закрываем
|
||||
_close_context(key)
|
||||
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
|
||||
|
||||
|
||||
|
||||
@@ -90,10 +90,7 @@ def generate_response(llm_config, vacancy, resume_text, style_hint=""):
|
||||
raise RuntimeError(f"LLM ошибка {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
data = resp.json()
|
||||
content = (data["choices"][0]["message"].get("content") or "").strip()
|
||||
if not content:
|
||||
raise RuntimeError("LLM вернул пустой текст — попробуйте ещё раз")
|
||||
return content
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
def generate_response_with_retry(llm_config, vacancy, resume_text, style_hint="", attempts=2):
|
||||
|
||||
+4
-22
@@ -656,8 +656,6 @@ 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');
|
||||
@@ -679,24 +677,14 @@ async function sendFromModal() {
|
||||
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
|
||||
method: 'POST', body: JSON.stringify({ id: sendModalId, message }),
|
||||
});
|
||||
if (data.ok) {
|
||||
// Отклик отправлен — статус меняем всегда, даже если скриншота нет
|
||||
if (data.screenshot) {
|
||||
showShotModal(sendModalId, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||
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;
|
||||
@@ -749,19 +737,13 @@ async function sendVacancy(id) {
|
||||
method: 'POST', body: JSON.stringify({ id, message }),
|
||||
});
|
||||
if (note) note.textContent = data.note || '';
|
||||
if (data.ok) {
|
||||
if (data.screenshot) {
|
||||
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
|
||||
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 || 'Не удалось отправить отклик');
|
||||
}
|
||||
|
||||
@@ -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_with_retry
|
||||
from hh_llm import generate_response
|
||||
from hh_browser import browser_open, browser_send, browser_close
|
||||
|
||||
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 {}
|
||||
vacancy_full = {**vacancy, **details}
|
||||
try:
|
||||
texts[vid] = generate_response_with_retry(cfg.get("llm", {}), vacancy_full,
|
||||
resume, cfg.get("style", ""))
|
||||
texts[vid] = generate_response(cfg.get("llm", {}), vacancy_full,
|
||||
resume, cfg.get("style", ""))
|
||||
if limit is not None:
|
||||
used += 1
|
||||
bump_llm_usage(current_user())
|
||||
|
||||
Reference in new issue
Block a user