diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index a4c686d0..659b39df 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -40,6 +40,7 @@ from app.services.scrapers.avito_imv import ( save_imv_placement_history, ) from app.services.scrapers.base import save_listings +from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.cian import CianScraper from app.services.scrapers.n1 import N1Scraper from app.services.scrapers.yandex_detail import YandexDetailScraper @@ -346,8 +347,6 @@ async def cian_auto_login( detail="CIAN_LOGIN_EMAIL/PASSWORD not configured", ) - from app.services.scrapers.browser_fetcher import BrowserFetcher - try: async with BrowserFetcher() as fetcher: raw_cookies = await fetcher.login( @@ -358,12 +357,14 @@ async def cian_auto_login( password_selector=settings.cian_login_password_selector, submit_selector=settings.cian_login_submit_selector, success_cookie=settings.cian_login_success_cookie, - pre_click_selector=settings.cian_login_pre_click_selector, + pre_click_selectors=settings.cian_login_pre_click_selectors, wait_ms=settings.cian_login_wait_ms, ) except Exception as exc: - logger.error("cian-auto-login: browser login failed: %s", type(exc).__name__) - raise HTTPException(status_code=502, detail=f"Browser login failed: {exc}") from exc + logger.error("cian auto-login failed: %s", type(exc).__name__) + raise HTTPException( + status_code=502, detail="Browser login failed (see server logs)" + ) from exc cleaned = {k: v for k, v in raw_cookies.items() if k in cian_session_svc.CIAN_REQUIRED_COOKIES} if not cleaned: diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d3f67c79..1d47f7cf 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -214,17 +214,21 @@ class Settings(BaseSettings): yandex_cookies_file: str | None = None # ── #639: Cian browser auto-login (Variant B) ──────────────────────────── - # Headless-браузер логинится в cian.ru email+паролем → извлекает auth-cookies → - # save_session. Подтверждено: вход без SMS/OTP. Креды пусты в dev → endpoint 503. - # Все селекторы env-overridable: при смене DOM cian тюним через .env.runtime БЕЗ - # редеплоя кода (restart backend достаточно). ENV: CIAN_LOGIN_EMAIL, CIAN_LOGIN_PASSWORD, ... + # Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый + # (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable. cian_login_email: str | None = None cian_login_password: str | None = None - cian_login_url: str = "https://www.cian.ru/zhalobi-i-otzivi/" - cian_login_pre_click_selector: str = "" + cian_login_url: str = "https://ekb.cian.ru/" + # Последовательность кликов до формы: открыть модалку → (опц.) другой аккаунт → + # переключить на email-вход. AnotherAccountBtn на fresh headless отсутствует (скипнется). + cian_login_pre_click_selectors: list[str] = [ + "[data-name='LoginButton']", + "[data-name='AnotherAccountBtn']", + "[data-name='SwitchToEmailAuthBtn']", + ] cian_login_email_selector: str = "input[name='username']" - cian_login_password_selector: str = "input[type='password']" - cian_login_submit_selector: str = "button[type='submit']" + cian_login_password_selector: str = "input[name='password']" + cian_login_submit_selector: str = "button[data-name='ContinueAuthBtn']" cian_login_success_cookie: str = "DMIR_AUTH" cian_login_wait_ms: int = 4000 diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py index c7b7328a..e0614534 100644 --- a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py +++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py @@ -96,7 +96,7 @@ class BrowserFetcher: password_selector: str, submit_selector: str, success_cookie: str, - pre_click_selector: str = "", + pre_click_selectors: list[str] | None = None, wait_ms: int | None = None, ) -> dict[str, str]: """Логинится через tradein-browser /login и возвращает cookies как dict name→value. @@ -118,7 +118,7 @@ class BrowserFetcher: "password_selector": password_selector, "submit_selector": submit_selector, "success_cookie": success_cookie, - "pre_click_selector": pre_click_selector, + "pre_click_selectors": pre_click_selectors or [], } if wait_ms is not None: body["wait_ms"] = wait_ms diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index afd0b710..0d2081ed 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -22,6 +22,11 @@ WS-сервер) несовместим с современными версия AVITO_PROXY_URL — прокси ``http://user:pass@host:port`` (mobileproxy, #623); SCRAPER_PROXY_URL — generic-fallback. Без него — soft-block. +Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS): + pre_click_selectors — список селекторов для последовательного клика до формы; + каждый клик non-fatal (пропускается при отсутствии элемента). Двухшаговый + submit: после первого сабмита Cian может показать «Введите пароль» повторно. + Эндпоинт /fetch доступен из Docker-сети как ``http://tradein-browser:3000/fetch``. """ @@ -318,8 +323,27 @@ async def _do_login(params: dict) -> list[dict]: raise +async def _wait_cookie(page: object, name: str, tries: int, interval_ms: int) -> bool: + """Ожидает появления cookie с заданным именем в контексте страницы. + + Returns: + True если cookie появился в течение tries × interval_ms мс, иначе False. + """ + for _ in range(tries): + cookies = await page.context.cookies() # type: ignore[attr-defined] + if any(c["name"] == name for c in cookies): + return True + await page.wait_for_timeout(interval_ms) # type: ignore[attr-defined] + return False + + async def _login_once(params: dict) -> list[dict]: - """Открывает страницу, заполняет форму, нажимает submit, ждёт success_cookie.""" + """Открывает страницу, заполняет форму, нажимает submit, ждёт success_cookie. + + Поддерживает список pre_click_selectors (клик по очереди, каждый non-fatal) + и 2-шаговый submit Cian: после первого сабмита появляется экран + «Введите пароль» — дозаполняем password и кликаем повторно (макс. 2 попытки). + """ global _page_counter browser = _browser @@ -331,44 +355,57 @@ async def _login_once(params: dict) -> list[dict]: email_selector: str = params["email_selector"] password_selector: str = params["password_selector"] submit_selector: str = params["submit_selector"] - pre_click_selector: str = params.get("pre_click_selector", "") or "" + pre_click_selectors: list[str] = params.get("pre_click_selectors") or [] success_cookie: str = params.get("success_cookie", "") wait_ms: int = params.get("wait_ms") or BROWSER_WAIT_MS page = await browser.new_page() # type: ignore[attr-defined] try: - await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] + await page.goto( # type: ignore[attr-defined] + url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" + ) await page.wait_for_timeout(wait_ms) # type: ignore[attr-defined] - if pre_click_selector: + # Pre-click sequence: каждый клик non-fatal (AnotherAccountBtn на fresh + # headless-сессии отсутствует — молча пропускается). + for sel in pre_click_selectors: try: - await page.click(pre_click_selector, timeout=15000) # type: ignore[attr-defined] - await page.wait_for_timeout(1500) # type: ignore[attr-defined] - except Exception as pre_exc: - logger.warning( - "tradein-browser: pre_click_selector=%r не найден (не фатально): %s", - pre_click_selector, - pre_exc, - ) + await page.click(sel, timeout=12000) # type: ignore[attr-defined] + await page.wait_for_timeout(1200) # type: ignore[attr-defined] + except Exception: + logger.info("login: pre-click skipped: %s", sel) await page.fill(email_selector, email, timeout=15000) # type: ignore[attr-defined] await page.fill(password_selector, password, timeout=15000) # type: ignore[attr-defined] - await page.click(submit_selector, timeout=15000) # type: ignore[attr-defined] - # Ждём появления success_cookie до ~20s + # 2-шаговый submit: Cian после первого сабмита показывает экран + # «Введите пароль» с тем же password_selector — дозаполняем и повторяем. cookie_found = False - if success_cookie: - for _ in range(40): # 40 × 500ms = 20s - await page.wait_for_timeout(500) # type: ignore[attr-defined] - ctx_cookies = await page.context.cookies() # type: ignore[attr-defined] - if any(c.get("name") == success_cookie for c in ctx_cookies): - cookie_found = True - break - logger.info( - "tradein-browser: login success_cookie=%r present=%s", - success_cookie, - cookie_found, - ) + for attempt in range(2): + await page.click(submit_selector, timeout=15000) # type: ignore[attr-defined] + if success_cookie: + cookie_found = await _wait_cookie( + page, success_cookie, tries=20, interval_ms=500 + ) + if cookie_found: + break + # 2-step: если поле пароля ещё видимо — дозаполнить и повторить + still_visible = False + try: + still_visible = await page.is_visible(password_selector) # type: ignore[attr-defined] + except Exception: + pass + if attempt == 0 and still_visible: + await page.fill(password_selector, password, timeout=15000) # type: ignore[attr-defined] + await page.wait_for_timeout(800) # type: ignore[attr-defined] + continue + break + + logger.info( + "tradein-browser: login success_cookie=%r present=%s", + success_cookie, + cookie_found, + ) cookies: list[dict] = await page.context.cookies() # type: ignore[attr-defined] logger.info( @@ -407,7 +444,6 @@ async def _login_once(params: dict) -> list[dict]: return cookies - # ── entrypoint ─────────────────────────────────────────────────────────────────