#!/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_*). 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): ...} # Доступны ТОЛЬКО из браузерного потока. _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(2000) # hh.ru прячет поле письма за переключателем «Сопроводительное письмо» — # раскрываем его, иначе заполнится textarea вопроса скрининга (task_*) try: toggle = page.locator(LETTER_TOGGLE).first if toggle.is_visible(timeout=2000): toggle.click() page.wait_for_timeout(800) print(" ✓ Переключатель «Сопроводительное письмо» раскрыт") except Exception: pass # Выбор резюме (радио-кнопка 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 может закрыть модалку/перезагрузить страницу — # ловим любые ошибки, но отклик уже отправлен. try: page.wait_for_timeout(2500) shot = _save_screenshot(page, screenshot_dir, vacancy["id"]) 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))