fix(tradein): wire validated Cian login selectors + 2-step flow + review fixes (#639)
Провалидировано вживую: email+пароль без SMS. pre_click один->список (LoginButton->AnotherAccountBtn->SwitchToEmailAuthBtn), 2-step «Введите пароль» retry, реальные селекторы (input[name=username|password], ContinueAuthBtn). M1: 502 detail без page_url. M4: BrowserFetcher импорт наверх.
This commit is contained in:
parent
1f18a2ccdc
commit
683cb50245
4 changed files with 83 additions and 42 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue