407 lines
15 KiB
Python
407 lines
15 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("Откликнуться")',
|
|
]
|
|
MESSAGE_FIELDS = [
|
|
'textarea[data-qa="vacancy-response-letter"]',
|
|
'textarea[data-qa="vacancy-response-popup-form"] textarea',
|
|
'textarea',
|
|
]
|
|
SUBMIT_BUTTONS = [
|
|
'[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)."""
|
|
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
|
|
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(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()
|
|
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 может закрыть модалку/перезагрузить страницу —
|
|
# ловим любые ошибки, но браузер закрываем ВСЕГДА (finally).
|
|
try:
|
|
page.wait_for_timeout(2500)
|
|
shot = _save_screenshot(page, screenshot_dir, vacancy["id"])
|
|
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):
|
|
"""Кликает «Отправить» на открытой странице. Возвращает (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
|
|
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)) |