"""Stealth-fetch utilities for наш.дом.рф scraping. Pure-httpx hits ServicePipe WAF JS-challenge even with Playwright-exported cookies (different TLS-fingerprint = blocked). The proven pattern from data/sql/30_scrape_domrf.py is to issue `fetch()` calls *inside* the live Chromium page — same JA3 fingerprint as a real browser, cookies/CSP/CORS all handled natively. BrowserSession keeps the browser+page open for the lifetime of a sweep and exposes get_json() that calls page.evaluate(fetch_in_browser). """ from __future__ import annotations import asyncio import json import logging import random from typing import Any from urllib.parse import urlencode, urlsplit from playwright.async_api import Browser, BrowserContext, Page, async_playwright logger = logging.getLogger(__name__) BASE_URL = "https://xn--80az8a.xn--d1aqf.xn--p1ai" # Real Chrome 147 fingerprint (Win10 x64). Keep in sync with data/sql/30_scrape_domrf.py. USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" ) # Максимум одновременных in-page fetch() на одну BrowserSession; # поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant. # Это МОДУЛЬНЫЙ ДЕФОЛТ — используется catalog/nspd/прочими скраперами. KN-sweep с # 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через # BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2. _BROWSER_CONCURRENCY = 8 # Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через # BrowserSession(jitter_min_ms=, jitter_max_ms=) против rate-бана (#1945). _DEFAULT_JITTER_MIN_MS = 600 _DEFAULT_JITTER_MAX_MS = 1500 def parse_proxy_url(proxy_url: str | None) -> dict[str, str] | None: """Распарсить ``http://user:pass@host:port`` в Playwright proxy-dict. Playwright ждёт ``{"server": "http://host:port", "username": ..., "password": ...}`` (creds — отдельные ключи, НЕ в server-URL). Возвращает None для пустого/None входа → вызывающий код запускает Chromium без прокси (поведение по умолчанию). Тот же URL-формат, что использует tradein-стек (SCRAPER_PROXY_URL). """ if not proxy_url: return None parts = urlsplit(proxy_url) if not parts.hostname: # НЕ эхо-им сырой proxy_url — он содержит пароль, а это исключение # всплывает в kn_scrape_runs.error / log_progress / Sentry (#1945 sec-review). raise ValueError(f"proxy URL без host (scheme={parts.scheme!r})") scheme = parts.scheme or "http" server = f"{scheme}://{parts.hostname}" if parts.port: server += f":{parts.port}" out: dict[str, str] = {"server": server} if parts.username: out["username"] = parts.username if parts.password: out["password"] = parts.password return out # Маппинг region_code → URL-сегмент города для реалистичного Referer. # Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/. REGION_LANDING_PATH = { 66: "/новостройки/строящиеся/екатеринбург/", 77: "/новостройки/строящиеся/москва/", 78: "/новостройки/строящиеся/санкт-петербург/", 50: "/новостройки/строящиеся/московская-область/", 47: "/новостройки/строящиеся/ленинградская-область/", } def make_referer(region_code: int) -> str: path = REGION_LANDING_PATH.get(region_code, "/новостройки/строящиеся/") return BASE_URL + path async def jitter_sleep(min_ms: int = 600, max_ms: int = 1500) -> None: delay = random.uniform(min_ms / 1000, max_ms / 1000) await asyncio.sleep(delay) # JS executed inside the live page. Returns {ok, status, body, contentType}. # Auth header is hardcoded `Basic MTpxd2U=` (decodes to `1:qwe`, the public # debug auth shipped in the site's frontend bundle for the kn API). _FETCH_JS = """ async ({url, auth}) => { const headers = {'Accept': 'application/json, text/plain, */*'}; if (auth) headers['Authorization'] = auth; try { const r = await fetch(url, {credentials: 'include', headers}); const ctype = r.headers.get('content-type') || ''; const body = await r.text(); return {ok: r.ok, status: r.status, body, contentType: ctype}; } catch (e) { return {ok: false, status: 0, body: String(e), contentType: ''}; } } """ class WafBlockedError(RuntimeError): """Server returned non-JSON (likely ServicePipe JS-challenge).""" class BrowserSession: """Keeps a live Playwright Chromium page; issues kn-API fetches inside it. Use as async context manager. Concurrency is bounded by an asyncio.Semaphore of size ``_BROWSER_CONCURRENCY`` (the same page can serve multiple concurrent fetches via JS-await). """ def __init__( self, region_code: int = 66, headed: bool = False, auth: str | None = "Basic MTpxd2U=", load_state: str | None = None, save_state: str | None = None, concurrency: int | None = None, jitter_min_ms: int | None = None, jitter_max_ms: int | None = None, proxy_url: str | None = None, ) -> None: self.region_code = region_code self.headed = headed self.auth = auth # Path to a Playwright storage_state JSON (cookies + localStorage). # If load_state is set, the context is created with these cookies preloaded — # ServicePipe sees a returning user, no fresh JS-challenge. # If save_state is set, the post-bootstrap state is dumped to that path # so it can be committed and reused on a server. self.load_state = load_state self.save_state = save_state # Per-instance concurrency (#1945): KN-sweep throttles to 2 against the # volume-ban; catalog/nspd callers pass nothing → keep the module default # _BROWSER_CONCURRENCY=8 (их поведение НЕ меняется). self._concurrency = concurrency if concurrency is not None else _BROWSER_CONCURRENCY # Per-instance inter-request jitter window (#1945). None → module default. self._jitter_min_ms = jitter_min_ms if jitter_min_ms is not None else _DEFAULT_JITTER_MIN_MS self._jitter_max_ms = jitter_max_ms if jitter_max_ms is not None else _DEFAULT_JITTER_MAX_MS # Optional rotating proxy (#1945). None → Chromium launches direct (default). self._proxy = parse_proxy_url(proxy_url) self._pw: Any = None self._browser: Browser | None = None self._context: BrowserContext | None = None self._page: Page | None = None self._sem = asyncio.Semaphore(self._concurrency) self._request_count = 0 self._warmed_up = False async def __aenter__(self) -> BrowserSession: await self._bootstrap() return self async def __aexit__(self, *exc: Any) -> None: if self._browser is not None: await self._browser.close() if self._pw is not None: await self._pw.stop() async def _bootstrap(self) -> None: landing_url = make_referer(self.region_code) logger.info("bootstrap: opening %s", landing_url) self._pw = await async_playwright().start() launch_kwargs: dict[str, Any] = {"headless": not self.headed} ctx_kwargs: dict[str, Any] = { "user_agent": USER_AGENT, "locale": "ru-RU", "viewport": {"width": 1920, "height": 1080}, } if self._proxy is not None: # Прокси задаётся и на launch, и на context (Playwright требует на # browser-level для Chromium; context-level дублирует для надёжности). launch_kwargs["proxy"] = self._proxy ctx_kwargs["proxy"] = self._proxy logger.info("bootstrap: routing Chromium through proxy %s", self._proxy["server"]) self._browser = await self._pw.chromium.launch(**launch_kwargs) if self.load_state: ctx_kwargs["storage_state"] = self.load_state logger.info("bootstrap: loading saved storage_state from %s", self.load_state) self._context = await self._browser.new_context(**ctx_kwargs) self._page = await self._context.new_page() await self._page.goto(landing_url, wait_until="domcontentloaded", timeout=30_000) try: await self._page.wait_for_load_state("networkidle", timeout=10_000) except Exception: pass if self.save_state: await self._context.storage_state(path=self.save_state) logger.info("bootstrap: storage_state saved to %s", self.save_state) logger.info("bootstrap: page ready, WAF challenge passed") async def warm_up(self, force: bool = False) -> None: """Visit catalog listing to obtain WAF cookies (___dmpkit___, domain_sid). DOM.РФ WAF (накатан между 2026-05-17 и 2026-05-24) требует session cookies для любого запроса на /сервисы/api/object/*/*. Cookies выдаются JS-челленджем при visit на /сервисы/каталог-новостроек/. Один warm-up на BrowserSession достаточен — cookies валидны для всего домена. Idempotent: повторные вызовы без force=True — no-op после первого успешного warm_up. """ if self._warmed_up and not force: return if self._context is None: raise RuntimeError("BrowserSession not bootstrapped — call inside `async with` block") page = await self._context.new_page() try: await page.goto( f"{BASE_URL}/сервисы/каталог-новостроек/", wait_until="domcontentloaded", timeout=30_000, ) await page.wait_for_timeout(2000) # JS challenge ставит cookies async cookies = await self._context.cookies() names = {c["name"] for c in cookies} critical = {"___dmpkit___", "domain_sid"} got = names & critical if not got: logger.warning( "warm_up: WAF cookies missing after catalog visit (have: %s)", sorted(names), ) else: logger.info("warm_up: got WAF cookies %s (total=%d)", sorted(got), len(cookies)) self._warmed_up = True finally: await page.close() async def get_json(self, path: str, params: dict[str, Any]) -> dict[str, Any]: """Fetch JSON via fetch() in browser. Retries with backoff on transient errors.""" if self._page is None: raise RuntimeError("BrowserSession not bootstrapped") url = BASE_URL + path + "?" + urlencode(params) last_err: Exception | None = None for attempt in range(5): async with self._sem: await jitter_sleep(self._jitter_min_ms, self._jitter_max_ms) try: self._request_count += 1 result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth}) except Exception as e: last_err = e logger.warning("evaluate err attempt=%d: %r", attempt, e) await asyncio.sleep(2**attempt) continue status = result.get("status", 0) body = result.get("body", "") ctype = result.get("contentType", "") if status in (429,) or status >= 500 or status == 0: last_err = RuntimeError(f"transient status={status}") logger.warning("transient status=%d attempt=%d, backing off", status, attempt) await asyncio.sleep(2**attempt) continue if "application/json" not in ctype: # WAF returned the JS challenge HTML; re-bootstrap could help, but for now fail. raise WafBlockedError( f"non-JSON response: status={status} ctype={ctype} body[:120]={body[:120]!r}" ) if status != 200: raise RuntimeError(f"http {status}: {body[:200]}") return json.loads(body) raise RuntimeError(f"max retries exhausted: {last_err!r}") @property def request_count(self) -> int: return self._request_count async def download_binary(self, url: str) -> bytes: """Download a binary asset (e.g. photo PNG) reusing browser cookies/auth. Uses Playwright APIRequest which goes through the browser context — same cookies, same TLS fingerprint as the page itself. """ if self._context is None: raise RuntimeError("BrowserSession not bootstrapped") async with self._sem: await jitter_sleep(200, 500) # Lighter throttle for static assets. self._request_count += 1 resp = await self._context.request.get( url, headers={"Authorization": self.auth} if self.auth else {}, ) if resp.status != 200: body = await resp.text() raise RuntimeError(f"binary http {resp.status}: {body[:200]}") return await resp.body()