#!/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("Отправить")', ] # Живые браузерные контексты и страницы: {(user, account): ...} # Доступны ТОЛЬКО из браузерного потока. _browsers = {} _pages = {} # ---------- Браузерный поток ---------- _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(account_dir): """Запускает браузер с профилем аккаунта и подставляет cookies сессии.""" p = sync_playwright().start() 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 _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) if key not in _browsers: _browsers[key] = _launch_context(account_dir) return _browsers[key] 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 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 context = _get_context(account_dir, user, account) page = context.new_page() _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 # Ждём подтверждение и снимаем скриншот результата page.wait_for_timeout(2500) shot = _save_screenshot(page, screenshot_dir, vacancy["id"]) 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() page.wait_for_timeout(1500) return True, "Кнопка «Отправить» нажата. Проверьте статус отклика на hh.ru." except Exception: continue return False, "Не нашёл кнопку «Отправить» — возможно, форма уже закрыта" def open_login_page(account_dir, user, account): """Открывает hh.ru в браузере (для ручного входа, локальный режим с экраном).""" key = (user, account) context = _get_context(account_dir, user, account) page = context.new_page() _pages[key] = page page.goto("https://hh.ru", timeout=45000) return True, "Страница hh.ru открыта" def close_context(user, account): key = (user, account) if key in _browsers: try: _browsers[key].close() except Exception: pass _browsers.pop(key, None) _pages.pop(key, None) # ---------- Публичные обёртки (можно вызывать из любого потока) ---------- 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))