Files
hh_auto/hh_browser.py

462 lines
19 KiB
Python

#!/usr/bin/env python3
"""Работа с браузером: открытие вакансии, клик «Откликнуться», вставка текста,
скриншот для проверки человеком и клик «Отправить» по подтверждению.
Без VNC: браузер работает headless (или на Xvfb-дисплее сервера), а человек
проверяет скриншот заполненной формы в веб-интерфейсе и подтверждает отправку.
Все операции с браузером выполняются в ОДНОМ выделенном потоке
(run_in_browser): Playwright sync API не потокобезопасен.
"""
import os
import queue
import threading
import time
from playwright.sync_api import sync_playwright
from dotenv import load_dotenv
load_dotenv()
from hh_search import parse_cookies
# Селекторы hh.ru (с запасными вариантами)
RESPONSE_BUTTONS = [
'[data-qa="vacancy-response-link-top"]',
'[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',
]
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): ...}
# Доступны ТОЛЬКО из браузерного потока.
_browsers = {}
_pages = {}
_playwrights = {}
# ---------- Браузерный поток ----------
_jobs = queue.Queue()
_browser_thread = None
def _browser_worker():
while True:
job = _jobs.get()
try:
job()
except Exception as e:
print(f" [browser] ошибка в фоновом задании: {e}")
finally:
_jobs.task_done()
def run_in_browser(fn):
"""Выполняет fn в единственном браузерном потоке и ждёт результат.
fn не принимает аргументов; исключения пробрасываются вызывающему.
"""
global _browser_thread
if _browser_thread is None or not _browser_thread.is_alive():
_browser_thread = threading.Thread(target=_browser_worker, daemon=True)
_browser_thread.start()
done = threading.Event()
result = {}
def wrapper():
try:
result["value"] = fn()
except Exception as e:
result["error"] = e
finally:
done.set()
_jobs.put(wrapper)
done.wait()
if "error" in result:
raise result["error"]
return result["value"]
# ---------- Настройки ----------
def is_headless():
"""HH_HEADLESS=1 — браузер без окна (не нужен дисплей)."""
return os.environ.get("HH_HEADLESS", "0") == "1"
def get_proxy_config():
"""Прокси для Playwright из .env: PROXY_URL=http://LOGIN:PASS@HOST:PORT"""
url = os.environ.get("PROXY_URL", "")
if not url:
return None
from urllib.parse import urlsplit
p = urlsplit(url)
cfg = {"server": f"{p.scheme}://{p.hostname}:{p.port or 8000}"}
if p.username:
cfg["username"] = p.username
cfg["password"] = p.password or ""
return cfg
# ---------- Внутренние функции (только из браузерного потока) ----------
def _launch_context_with(p, account_dir):
"""Запускает браузер с профилем аккаунта и подставляет cookies сессии."""
kwargs = dict(
user_data_dir=os.path.join(account_dir, "profile"),
headless=is_headless(),
viewport={"width": 1400, "height": 900},
proxy=get_proxy_config(),
)
if os.environ.get("HH_NO_SANDBOX"):
kwargs["args"] = ["--no-sandbox"]
context = p.chromium.launch_persistent_context(**kwargs)
_inject_session_cookies(context, account_dir)
return context
def _context_alive(ctx):
"""Проверяет, жив ли контекст (лёгкая операция, не создаёт страниц)."""
try:
ctx.pages
return True
except Exception:
return False
def _inject_session_cookies(context, account_dir):
"""Подставляет cookies из session.txt — вход на hh.ru без ручного логина."""
path = os.path.join(account_dir, "session.txt")
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as f:
cookie_str = f.read().strip()
if not cookie_str:
return
cookies = parse_cookies(cookie_str)
if not cookies:
return
try:
context.add_cookies([
{"name": k, "value": v, "url": "https://hh.ru"}
for k, v in cookies.items()
])
except Exception as e:
print(f" ⚠ Не удалось подставить cookies: {e}")
def _get_context(account_dir, user, account):
"""Возвращает живой контекст; если браузер умер — перезапускает."""
key = (user, account)
ctx = _browsers.get(key)
if ctx is None or not _context_alive(ctx):
if ctx is not None:
print(f" ⚠ Браузер {key} закрыт — перезапускаю")
_close_context(key)
p = sync_playwright().start()
try:
ctx = _launch_context_with(p, account_dir)
except Exception:
p.stop()
raise
_browsers[key] = ctx
_playwrights[key] = p
return ctx
def _new_page(account_dir, user, account):
"""Создаёт страницу; если контекст умер между проверкой и созданием —
перезапускает браузер и пробует ещё раз."""
try:
return _get_context(account_dir, user, account).new_page()
except Exception:
print(f" ⚠ Не удалось создать страницу для {(user, account)} — перезапускаю браузер")
_close_context((user, account))
return _get_context(account_dir, user, account).new_page()
def _save_screenshot(page, screenshot_dir, vid):
if not screenshot_dir:
return None
os.makedirs(screenshot_dir, exist_ok=True)
path = os.path.join(screenshot_dir, f"{vid}.png")
try:
page.screenshot(path=path)
return path
except Exception:
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).
Браузер закрывается ВСЕГДА, кроме ручного режима (submit=False, успех) —
там он нужен для confirm_send."""
key = (user, account)
# Закрываем предыдущую страницу этого аккаунта
prev = _pages.pop(key, None)
if prev:
try:
prev.close()
except Exception:
pass
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)
# Кнопка «Откликнуться»
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(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):
"""Кликает «Отправить» на открытой странице. Возвращает (ok, note)."""
key = (user, account)
page = _pages.get(key)
if not page:
return False, "Страница не открыта — сначала нажмите «Открыть»"
for sel in SUBMIT_BUTTONS:
try:
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:
pass
finally:
# Отклик отправлен — закрываем браузер автоматически
_close_context(key)
return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru."
except Exception:
continue
# Кнопка не найдена — браузер больше не нужен, закрываем
_close_context(key)
return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта"
def open_login_page(account_dir, user, account):
"""Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном)."""
key = (user, account)
page = _new_page(account_dir, user, account)
_pages[key] = page
page.goto("https://hh.ru", timeout=45000)
return True, "Страница hh.ru открыта"
def _close_context(key):
"""Закрывает контекст и останавливает playwright-инстанс (из браузерного потока)."""
if key in _browsers:
try:
_browsers[key].close()
except Exception:
pass
_browsers.pop(key, None)
_pages.pop(key, None)
p = _playwrights.pop(key, None)
if p:
try:
p.stop()
except Exception:
pass
def close_context(user, account):
_close_context((user, account))
# ---------- Публичные обёртки (можно вызывать из любого потока) ----------
def browser_open(account_dir, user, account, vacancy, message,
resume_id=None, screenshot_dir=None, submit=False):
return run_in_browser(lambda: open_response_page(
account_dir, user, account, vacancy, message,
resume_id=resume_id, screenshot_dir=screenshot_dir, submit=submit))
def browser_send(user, account, vid):
return run_in_browser(lambda: confirm_send(user, account, vid))
def browser_open_login(account_dir, user, account):
return run_in_browser(lambda: open_login_page(account_dir, user, account))
def browser_close(user, account):
return run_in_browser(lambda: close_context(user, account))