#!/usr/bin/env python3 """DomClick EKB вторичка — SELF-CONTAINED локальный сборщик (search + detail). ЗАЧЕМ ОТДЕЛЬНЫЙ СКРИПТ ===================== DomClick фронтит QRATOR anti-bot. Проверено вживую (2026-06-27): • запрос с ДОМАШНЕГО (резидентного) IP → отдаёт полную карточку с ``window.__SSR_STATE__`` (renovation / priceHistory / egrnData / AVM); • запрос с datacenter / mobile-proxy IP → 403 | Домклик (bot_mitigation). Поэтому продовый pipeline (BrowserFetcher через shared proxy) карточки НЕ вытягивает. Этот скрипт запускается ОПЕРАТОРОМ на его собственной машине с домашнего IP — без прокси — и собирает данные в JSONL, который потом заливается в trade-in БД ОТДЕЛЬНЫМ шагом (этот скрипт в БД НЕ пишет). САМОДОСТАТОЧНОСТЬ ================= Никаких ``app.*`` импортов: вся логика Layer A (enumerate) и Layer B (detail ``__SSR_STATE__`` parse) скопирована ИНЛАЙН из ``app/services/scrapers/domclick.py`` + ``domclick_detail.py`` + ``repair_state_normalizer.py``. Зависимости: stdlib + ``playwright.async_api``. ТРАНСПОРТ ========= Playwright Chromium, БЕЗ прокси (домашний IP). И enumerate, и detail идут ОДНИМ браузерным транспортом ``page.goto`` (verified 2026-06-27): • enumerate — ``page.goto(SERP_url)`` на фронтовый ``ekaterinburg.domclick.ru/search?...`` (SSR). Прямой BFF ``bff-search-web.domclick.ru/api/offers/v1`` режет QRATOR-403 уже с домашнего IP, а фронтовый SERP грузится чисто (тот же транспорт, что card-detail). id офферов берём из DOM (``a[href*="/card/"]``), total — из ```` ("… N объявлений"). Прямого offers/v1 XHR фронт НЕ шлёт. Пагинация — параметром ``offset`` (page-size 20); при total>2000 — price-bisection бакета (границы тоже из title-total). • detail — ``page.goto(card_url)`` → jittered wait → ``page.evaluate`` с ``fetch(location.href)`` чтобы получить СЫРОЙ HTML (SPA удаляет ``window.__SSR_STATE__`` после гидрации, поэтому нужен повторный fetch). Layer-A (price/area/rooms/floor/total_floors/lat/lon/address) теперь берётся ИЗ card-SSR (enumerate отдаёт только id+url). ВАЖНО — РЕЖИМ ОКНА (verified 2026-06-27 на домашнем IP этой машины) ------------------------------------------------------------------ QRATOR ДЕТЕКТИТ СТАРЫЙ HEADLESS-РЕЖИМ: ``launch(headless=True)`` → даже с домашнего IP карточка/SERP отдаёт ``401 / 403 | Домклик`` (нет ``__SSR_STATE__``). А вот НОВЫЙ headless (``--headless=new``) И обычный headed → ``200`` + полный рендер. Поэтому по умолчанию запускаемся в new-headless (окно НЕ видно, но детект проходит). ``--headed`` → видимое окно (debug). ВАЖНО — ЧЕЛОВЕКОПОДОБНЫЙ ТЕМП (явное требование оператора) ========================================================= Домашний IP — расходник: спалим частотой → бан и потеря единственного рабочего канала. Поэтому темп НАМЕРЕННО медленный и настраиваемый: • случайная пауза МЕЖДУ карточками: ``--min-delay`` (12с) / ``--max-delay`` (30с), ``random.uniform``; • jittered render-wait после goto: ``--render-min`` (6с) / ``--render-max`` (10с); • «человеческий перерыв»: каждые 25-40 карточек → сон 60-180с; • на 403 — ОДИН ретрай через 45-90с; всё ещё блок → status="blocked" и идём дальше (НЕ долбим). При дефолтных задержках темп ≈ 100-150 карточек/час. Полный свод ЕКБ-вторички (~6400 карточек) → ≈ 45-65 часов реального времени, т.е. несколько ночей. Скрипт РЕЗЮМИРУЕМ: при старте читает уже собранный JSONL и пропускает готовые id (status ok/blocked/parse_fail), так что прогон можно дробить на сессии. ЗАПУСК ====== Нужен python с установленным playwright (``python -c "import playwright"``). :: cd .../tradein-mvp/backend # боевой прогон (дефолтный медленный темп), дописывает в JSONL: python scripts/domclick_local_runner.py --out domclick_ekb.jsonl # быстрый smoke (только для теста — маленькие задержки!): python scripts/domclick_local_runner.py --limit 2 --min-delay 3 --max-delay 5 \ --out /tmp/dc_test.jsonl # только перечислить карточки, без detail: python scripts/domclick_local_runner.py --enumerate-only --out enum.jsonl OUTPUT ====== JSONL, по одной записи на карточку (append). Поля см. ``build_record``. Файл потребляется ОТДЕЛЬНЫМ ingest-шагом в trade-in БД (listings + offer_price_history); этот скрипт сам в БД ничего не пишет. """ from __future__ import annotations import argparse import asyncio import json import logging import random import re from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import urlencode from playwright.async_api import async_playwright logger = logging.getLogger("domclick_local") # ── API / гео константы (ground truth, verified 2026-06-27) ────────────────── _EKB_AREA_ID = "20561" # aids= фильтр ЕКБ-вторички на фронтовом SERP _SERP_HOST = "https://ekaterinburg.domclick.ru" _WARMUP_URL = "https://ekaterinburg.domclick.ru/" # Порядок бакетов комнат влияет на логи. КРИТИЧНО: "5+" обязан кодироваться как # "5%2B" — литеральный '+' в query фронт читает как пробел → фильтр игнорится и # отдаётся ПОЛНЫЙ свод (verified 2026-06-27). urlencode("5+") → "5%2B" — корректно. # "st" фронт принимает как студию. "5" в одиночку фронт НЕ понимает (только "5+"). ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+") OFFSET_CAP: int = 2000 # практический потолок offset фронтового SERP _SERP_PAGE_SIZE: int = 20 # офферов на страницу SERP (verified) # total из <title>: "... 2 269 объявлений" — число с разрядами (пробел/nbsp/thin). _SERP_TOTAL_RE = re.compile(r"(\d[\d\s]*)\s*объявлен") LTE_MAX: int = 1_000_000_000 # потолок цены для binary-split unbounded бакета MIN_PRICE_SPAN: int = 100_000 # ниже этого span деление прекращаем MAX_SPLIT_DEPTH: int = 30 # guard рекурсии binary-split (log2(1e9/1e5) ≈ 13) # Гео-фильтр ЕКБ обеспечивает aids=20561 в SERP-URL; bbox-guard убран вместе с # BFF-enumerate (фронт отдаёт только id+url, координаты — из card-SSR). # Маркеры QRATOR / anti-bot challenge. _BLOCK_MARKERS: tuple[str, ...] = ( "qrator", "bot_mitigation", "система защиты", "403 | домклик", "captcha", "datadome", "access denied", ) # Реалистичный desktop-Chrome UA (headless-UA палится anti-bot'ами). _USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ) # SSR-стейт встроен как JS-литерал (НЕ чистый JSON: bare `undefined`). _SSR_ASSIGN_RE = re.compile(r"window\.__SSR_STATE__\s*=\s*") _UNDEFINED_RE = re.compile(r"(?<=[:\[,\s])undefined(?=[\s,\]\}])") _ITEM_ID_RE = re.compile(r"sale__flat__(\d+)") # JS для повторного fetch сырого HTML карточки из контекста страницы. _FETCH_RAW_JS = ( "async () => { const r = await fetch(window.location.href, " "{credentials: 'include'}); return await r.text(); }" ) # JS: id+url всех офферов из DOM фронтового SERP (anchor'ы /card/...<8+ digits>). # Дедуп через Set по сериализованному объекту. Фронт — SSR, offers/v1 XHR НЕ шлёт. _SERP_IDS_JS = ( "() => [...new Set([...document.querySelectorAll('a[href*=\"/card/\"]')]" ".map(a => { const h=a.getAttribute('href')||'';" " const m=h.match(/\\/card\\/[a-z_]*?(\\d{8,})/);" " return m ? {id:m[1], url: h.startsWith('http')? h :" " 'https://ekaterinburg.domclick.ru'+h} : null; })" ".filter(Boolean).map(o => JSON.stringify(o)))].map(s => JSON.parse(s))" ) # ── Исключения ─────────────────────────────────────────────────────────────── class DomClickBlockedError(Exception): """Anti-bot challenge (QRATOR/DataDome/403) — НЕ parse-failure.""" class DomClickParseError(Exception): """HTTP 200, но __SSR_STATE__ не найден / не парсится (дрейф схемы).""" class _EnumLimitReachedError(Exception): """Внутренний сигнал: набрали target новых карточек — стоп enumerate.""" # ── repair_state normalizer (инлайн из repair_state_normalizer.py) ─────────── _RAW_TO_ENUM: dict[str, str] = { "required": "needs_repair", "without": "needs_repair", "no": "needs_repair", "rough": "needs_repair", "prefine": "needs_repair", "cosmetic": "standard", "euro": "good", "fine": "good", "designer": "excellent", "design": "excellent", } _VALID_ENUM: frozenset[str] = frozenset({"needs_repair", "standard", "good", "excellent"}) # renovation (RU) → каноничный enum (локальная карта Layer B). # Расширено относительно app-версии РЕАЛЬНЫМИ короткими токенами DomClick, # наблюдёнными вживую 2026-06-27 ("Евро", "Косметический"): SSR-поле renovation # отдаёт КОРОТКУЮ форму ("Евро"), а не "евроремонт" — иначе good терялся в None. _RENOVATION_MAP: dict[str, str] = { "евроремонт": "good", "евро": "good", "косметический": "standard", "косметический ремонт": "standard", "дизайнерский": "excellent", "дизайнерский ремонт": "excellent", "черновая": "needs_repair", "черновая отделка": "needs_repair", "без отделки": "needs_repair", "без ремонта": "needs_repair", "требует ремонта": "needs_repair", "предчистовая": "needs_repair", "предчистовая отделка": "needs_repair", } _TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( ( re.compile( r"дизайнерск(?:ий|ого|ом|ая)\s+ремонт" r"|дизайнерск(?:ая|ой)\s+отделк" r"|премиальн\w*\s+ремонт" r"|эксклюзивн\w*\s+ремонт" r"|авторск\w*\s+ремонт", re.IGNORECASE, ), "excellent", ), ( re.compile( r"евроремонт" r"|евро[\s-]?ремонт" r"|отличн\w*\s+ремонт" r"|свеж\w*\s+ремонт" r"|современн\w*\s+ремонт" r"|качественн\w*\s+ремонт" r"|ремонт\s+в\s+отличн", re.IGNORECASE, ), "good", ), ( re.compile( r"без\s+отделк" r"|без\s+ремонт" r"|черновая\s+отделк" r"|черновой\s+ремонт" r"|требует(?:ся)?\s+ремонт" r"|требует(?:ся)?\s+космет" r"|нужен\s+ремонт" r"|под\s+ремонт" r"|под\s+чистов\w+\s+отделк" r"|предчистов", re.IGNORECASE, ), "needs_repair", ), ( re.compile( r"косметическ\w*\s+ремонт" r"|косметическ\w*\s+отделк" r"|обычн\w*\s+ремонт" r"|жил(?:ое|ом)\s+состоян" r"|хорош\w*\s+состоян", re.IGNORECASE, ), "standard", ), ) def normalize_repair_state(raw: str | None) -> str | None: """raw repair → каноничный enum (идемпотентно). Неизвестное → None.""" if raw is None: return None if raw in _VALID_ENUM: return raw result = _RAW_TO_ENUM.get(raw) or _RAW_TO_ENUM.get(raw.lower()) if result is None: logger.debug("repair_state: unknown raw %r — stored NULL", raw) return result def infer_repair_state_from_text(description: str | None) -> str | None: """repair_state из текста описания (fallback). Первое совпадение по силе.""" if not description: return None for pattern, enum_value in _TEXT_PATTERNS: if pattern.search(description): return enum_value return None def _map_repair_state(renovation: str | None) -> str | None: """RU renovation → enum; нет совпадения в карте → инференс из текста.""" if not renovation: return None mapped = _RENOVATION_MAP.get(renovation.strip().lower()) if mapped is not None: return normalize_repair_state(mapped) return infer_repair_state_from_text(renovation) # ── value coercion (инлайн из domclick_detail.py) ──────────────────────────── def _to_float(value: Any) -> float | None: if value is None or isinstance(value, bool): return None try: return float(value) except (ValueError, TypeError): return None def _to_int(value: Any) -> int | None: if value is None or isinstance(value, bool): return None try: return int(value) except (ValueError, TypeError): return None def _pos_float(value: Any) -> float | None: """float > 0, иначе None. DomClick эмитит 0 как sentinel «неизвестно» для living/kitchen area (особенно у студий) — 0.0 ввело бы в заблуждение.""" f = _to_float(value) if f is None or f <= 0: return None return f def _parse_change_time(value: Any) -> datetime | None: """date изменения цены → tz-aware datetime. epoch(сек/мс)/ISO8601/num-str.""" if value is None or isinstance(value, bool): return None if isinstance(value, int | float): ts = float(value) if ts > 1e12: ts /= 1000.0 try: return datetime.fromtimestamp(ts, tz=UTC) except (ValueError, OSError, OverflowError): return None s = str(value).strip() if not s: return None if s.isdigit(): ts = float(s) if ts > 1e12: ts /= 1000.0 try: return datetime.fromtimestamp(ts, tz=UTC) except (ValueError, OSError, OverflowError): return None try: dt = datetime.fromisoformat(s.replace("Z", "+00:00")) except (ValueError, TypeError): return None if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt def _compact(d: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in d.items() if v is not None} def _pick(d: dict[str, Any], *keys: str) -> Any: for k in keys: v = d.get(k) if v is not None: return v return None def _extract_item_id(url: str) -> str | None: m = _ITEM_ID_RE.search(url or "") return m.group(1) if m else None # ── Block detection / SERP URL & parsing ───────────────────────────────────── def _is_block_text(text: str) -> bool: low = text.lower() return any(m in low for m in _BLOCK_MARKERS) def _build_serp_url(rooms: str, gte: int | None, lte: int | None, offset: int) -> str: """URL фронтового SERP. urlencode кодирует "5+" → "5%2B" (иначе фронт читает '+' как пробел и игнорит room-фильтр).""" params: list[tuple[str, str]] = [ ("deal_type", "sale"), ("category", "living"), ("offer_type", "flat"), ("aids", _EKB_AREA_ID), ("from", "topline2020"), ("offset", str(offset)), ("rooms", rooms), ] if gte is not None: params.append(("sale_price__gte", str(gte))) if lte is not None: params.append(("sale_price__lte", str(lte))) return f"{_SERP_HOST}/search?{urlencode(params)}" def _parse_serp_total(title: str) -> int | None: """total из <title> ("… 2 269 объявлений"). None если числа нет.""" if not title: return None m = _SERP_TOTAL_RE.search(title) if not m: return None digits = re.sub(r"\D", "", m.group(1)) return int(digits) if digits else None def _canon_card_url(sid: str, url: str | None) -> str: """Канонизируем source_url к /card/sale__flat__<id>, если href неполный.""" if url and url.startswith("http") and "/card/" in url: return url return f"{_SERP_HOST}/card/sale__flat__{sid}" def _serp_offer_to_dict(raw: Any) -> dict[str, Any] | None: """offer из _SERP_IDS_JS ({id,url}) → минимальный SERP-dict {id, source_url}. Layer-A (price/area/...) теперь добирается из card-SSR в parse_detail_html.""" if not isinstance(raw, dict): return None sid = raw.get("id") if sid is None: return None sid = str(sid) if not sid.isdigit(): return None return {"id": sid, "source_url": _canon_card_url(sid, raw.get("url"))} # ── SSR state extraction (инлайн из domclick_detail.py) ────────────────────── def _scan_balanced_object(s: str, start: int) -> str: """От '{' по индексу start до парной '}', игнорируя скобки внутри строк.""" depth = 0 in_string = False quote_char = "" escaped = False for i in range(start, len(s)): ch = s[i] if in_string: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == quote_char: in_string = False continue if ch in ('"', "'"): in_string = True quote_char = ch elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return s[start : i + 1] raise DomClickParseError("__SSR_STATE__ object not balanced (no matching brace)") def _extract_ssr_state(html: str) -> dict[str, Any]: """window.__SSR_STATE__ object-литерал из HTML карточки → dict. Raises: DomClickBlockedError: нет стейта + anti-bot маркеры. DomClickParseError: стейт не найден / не сбалансирован / json.loads упал. """ match = _SSR_ASSIGN_RE.search(html) if match is None: if _is_block_text(html): raise DomClickBlockedError("detail: challenge page (no __SSR_STATE__)") raise DomClickParseError("__SSR_STATE__ not found") brace_start = html.find("{", match.end()) if brace_start == -1: raise DomClickParseError("__SSR_STATE__ assignment has no opening '{'") literal = _scan_balanced_object(html, brace_start) sanitized = _UNDEFINED_RE.sub("null", literal) try: data = json.loads(sanitized) except json.JSONDecodeError as exc: raise DomClickParseError( f"__SSR_STATE__ json.loads failed: {exc} (head={sanitized[:160]!r})" ) from exc if not isinstance(data, dict): raise DomClickParseError(f"__SSR_STATE__ is not an object: {type(data)}") return data def _extract_owners_count(egrn: dict[str, Any]) -> int | None: """egrnData.owners_count. Ground truth: {status, value}; fallback на скаляр.""" oc = egrn.get("owners_count") if oc is None: oc = egrn.get("ownersCount") if isinstance(oc, dict): return _to_int(oc.get("value")) return _to_int(oc) def parse_detail_html(html: str, source_url: str) -> dict[str, Any]: """Парсер detail-карточки → dict обогащения. _extract_ssr_state может бросить Blocked/ParseError (propagate); навигация по полям обёрнута → минимум при сюрпризе. """ state = _extract_ssr_state(html) try: pc = state.get("productCard") or {} oi = pc.get("objectInfo") or {} hi = (state.get("houseInfo") or {}).get("info") or {} egrn = pc.get("egrnData") or {} renovation = oi.get("renovation") repair_raw = renovation if isinstance(renovation, str) else None repair_state = _map_repair_state(repair_raw) living = _pos_float(oi.get("livingArea")) kitchen = _pos_float(oi.get("kitchenArea")) sale_type = (pc.get("legalOptions") or {}).get("saleType") owners_count = _extract_owners_count(egrn) collateral = egrn.get("collateral") collateral_sber = egrn.get("collateral_sber") if egrn: encumbrances_clean: bool | None = not bool(collateral or collateral_sber) else: encumbrances_clean = None wall_type = hi.get("wallType") or (pc.get("house") or {}).get("wallType") floor_type = hi.get("floorType") year_built = _to_int(hi.get("buildYear")) views = _to_int(pc.get("viewsCount")) calls = _to_int(pc.get("callsCount")) # Layer-A из card-SSR (раньше шёл из BFF serp; теперь enumerate даёт # только id+url). Пути verified 2026-06-27: цена/squarePrice — в # priceInfo; area/rooms/floor — в objectInfo; total_floors — houseInfo; # lat/lon/address — productCard.address(.position). price_info = pc.get("priceInfo") or {} address = pc.get("address") or {} position = address.get("position") or {} price_rub = _to_int(price_info.get("price")) square_price = _to_int(price_info.get("squarePrice")) area_m2 = _pos_float(oi.get("area")) rooms = _to_int(oi.get("rooms")) floor = _to_int(oi.get("floor")) total_floors = _to_int(hi.get("floors")) or _to_int( (pc.get("house") or {}).get("info", {}).get("floors") ) lat = _to_float(position.get("lat")) lon = _to_float(position.get("lon")) address_name = address.get("displayName") is_apartment = oi.get("isApartment") description = oi.get("description") # priceHistory → нормализованные записи (skip при битой дате/без цены). raw_history = (pc.get("priceInfo") or {}).get("priceHistory") or [] price_changes: list[dict[str, Any]] = [] if isinstance(raw_history, list): for entry in raw_history: if not isinstance(entry, dict): continue ct = _parse_change_time(entry.get("date")) price = _to_int(entry.get("price")) if ct is None or price is None: continue diff = entry.get("diff") diff_pct = ( diff if isinstance(diff, int | float) and not isinstance(diff, bool) else None ) price_changes.append( { "change_time": ct.isoformat(), "price_rub": price, "diff_percent": diff_pct, "state": entry.get("state"), } ) # AVM — top-level pricePrediction (часто пусто); defensively разворачиваем. avm_root = state.get("pricePrediction") or {} avm_src: Any = avm_root if isinstance(avm_root, dict): avm_src = avm_root.get("result") or avm_root.get("data") or avm_root if not isinstance(avm_src, dict): avm_src = {} avm = _compact( { "market_price": _pick(avm_src, "market_price", "marketPrice"), "min": _pick(avm_src, "min_market_price", "minMarketPrice"), "max": _pick(avm_src, "max_market_price", "maxMarketPrice"), "repair_quality": _pick(avm_src, "repair_quality", "repairQuality"), } ) demand = _compact( { "calls": calls, "favorites": pc.get("favoriteOfferUsersCount"), "duplicates": pc.get("duplicatesOfferCount"), } ) raw_extra = _compact( { "wall_type": wall_type, "floor_type": floor_type, "entrance_count": hi.get("entranceCount"), "quarters_count": hi.get("quartersCount"), "energy_efficiency": hi.get("energyEfficiency"), "building_series": hi.get("buildingSeries"), "domclick_building_guid": (pc.get("address") or {}).get("guid"), "demand": demand or None, "collateral": collateral, "collateral_sber": collateral_sber, } ) return { # Layer-A (из card-SSR; раньше из BFF serp). "price_rub": price_rub, "square_price": square_price, "area_m2": area_m2, "rooms": rooms, "floor": floor, "total_floors": total_floors, "lat": lat, "lon": lon, "address": address_name, "is_apartment": is_apartment, "description": description, # Layer-B. "repair_state": repair_state, "repair_raw": repair_raw, "living_area_m2": living, "kitchen_area_m2": kitchen, "sale_type": sale_type if isinstance(sale_type, str) else None, "owners_count": owners_count, "encumbrances_clean": encumbrances_clean, "wall_type": wall_type, "floor_type": floor_type, "views": views, "calls": calls, "price_changes": price_changes, "collateral_sber": bool(collateral_sber) if collateral_sber is not None else None, "year_built": year_built, "avm": avm or None, "raw_extra": raw_extra, } except (DomClickBlockedError, DomClickParseError): raise except Exception: logger.warning("detail field navigation failed for %s — minimal", source_url, exc_info=True) return {} # ── Output record ──────────────────────────────────────────────────────────── def build_record( serp: dict[str, Any], detail: dict[str, Any] | None, status: str ) -> dict[str, Any]: """SERP + detail → итоговая JSONL-запись.""" d = detail or {} repair_state = d.get("repair_state") if not repair_state: # fallback-инференс из описания (теперь из card-SSR; serp-описания больше нет) repair_state = infer_repair_state_from_text(d.get("description") or serp.get("description")) is_sber = serp.get("is_sber_collateral") if not is_sber and d.get("collateral_sber"): is_sber = True # Layer-A: приоритет card-SSR (d), фолбэк на serp (на случай если serp когда-то # снова начнёт отдавать поля). enumerate теперь даёт только id+source_url. return { "id": serp.get("id"), "source_url": serp.get("source_url"), "status": status, "fetched_at": datetime.now(UTC).isoformat(), "price_rub": d.get("price_rub") or serp.get("price_rub"), "area_m2": d.get("area_m2") or serp.get("area_m2"), "rooms": d.get("rooms") if d.get("rooms") is not None else serp.get("rooms"), "floor": d.get("floor") or serp.get("floor"), "total_floors": d.get("total_floors") or serp.get("total_floors"), "year_built": d.get("year_built") or serp.get("year_built"), "lat": d.get("lat") or serp.get("lat"), "lon": d.get("lon") or serp.get("lon"), "address": d.get("address") or serp.get("address"), "repair_state": repair_state, "repair_raw": d.get("repair_raw"), "living_area_m2": d.get("living_area_m2"), "kitchen_area_m2": d.get("kitchen_area_m2"), "sale_type": d.get("sale_type"), "owners_count": d.get("owners_count"), "encumbrances_clean": d.get("encumbrances_clean"), "wall_type": d.get("wall_type"), "floor_type": d.get("floor_type"), "views": d.get("views"), "calls": d.get("calls"), "price_changes": d.get("price_changes") or [], "is_sber_collateral": is_sber, "avm": d.get("avm") or {}, "raw_extra": { "square_price": d.get("square_price") or serp.get("square_price"), "is_apartment": ( d.get("is_apartment") if d.get("is_apartment") is not None else serp.get("is_apartment") ), "offer_region_name": serp.get("offer_region_name"), "published_date": serp.get("published_date"), **(d.get("raw_extra") or {}), **({"sold_similar": d["sold_similar"]} if d.get("sold_similar") else {}), }, } # ── Resume / output IO ─────────────────────────────────────────────────────── _TERMINAL_STATUSES = frozenset({"ok", "blocked", "parse_fail"}) def load_done_ids(path: Path) -> set[str]: """id с терминальным detail-статусом из существующего JSONL (для resume).""" done: set[str] = set() if not path.exists(): return done with path.open("r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: continue rid = rec.get("id") if rid and rec.get("status") in _TERMINAL_STATUSES: done.add(str(rid)) return done def append_record(path: Path, rec: dict[str, Any]) -> None: with path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") fh.flush() # ── Enumerate cache (полный список id, чтобы НЕ гонять SERP заново) ──────────── def write_enum_cache(path: str, offers: list[dict[str, Any]]) -> None: """Перезаписать кэш-файл полным списком offer-id + sentinel завершённости. Каждый offer пишется как {"id":..., "source_url":...} (только 2 поля), финальной строкой — sentinel {"_enum_complete": true, "count": N}. Sentinel означает «enumerate завершился полностью» — без него load_enum_cache считает кэш неполным и заставляет re-enumerate. """ cache_path = Path(path) cache_path.parent.mkdir(parents=True, exist_ok=True) with cache_path.open("w", encoding="utf-8") as fh: for serp in offers: row = {"id": serp.get("id"), "source_url": serp.get("source_url")} fh.write(json.dumps(row, ensure_ascii=False) + "\n") fh.write(json.dumps({"_enum_complete": True, "count": len(offers)}) + "\n") fh.flush() logger.info("enum-cache: wrote %d ids → %s", len(offers), path) def load_enum_cache(path: str) -> list[dict[str, Any]] | None: """Загрузить полный список offer-id из кэша. None если файла нет/кэш неполный. Кэш валиден ТОЛЬКО если последняя валидная строка — sentinel с ``_enum_complete == True`` (enumerate завершился). Битые строки пропускаются; нет sentinel / пустой / повреждённый → None (заставляет re-enumerate). """ cache_path = Path(path) if not cache_path.exists(): return None records: list[dict[str, Any]] = [] with cache_path.open("r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: continue if isinstance(rec, dict): records.append(rec) if not records: return None last = records[-1] if not (isinstance(last, dict) and last.get("_enum_complete") is True): return None # кэш неполный (enumerate не завершился) → re-enumerate offers: list[dict[str, Any]] = [] for rec in records[:-1]: sid = rec.get("id") if sid is None: continue offers.append({"id": str(sid), "source_url": rec.get("source_url")}) logger.info("enum-cache: loaded %d ids (complete)", len(offers)) return offers # ── SERP transport (playwright page navigation, чистый браузерный транспорт) ── async def fetch_serp_page( page: Any, url: str, render_wait: tuple[float, float], ) -> tuple[list[dict[str, Any]], int | None, int]: """Навигация на фронтовый SERP → (offers, total, http_status). offers — список {id, source_url} из DOM; total — из <title>. Raises: DomClickBlockedError: 403 или QRATOR-маркеры в title. """ resp = await page.goto(url, wait_until="domcontentloaded", timeout=70000) # SERP рендерит карточки клиентской гидрацией через ~6-12с после domcontentloaded. # Фиксированный sleep хрупок: при медленном ответе DOM ещё пуст → 0 ids (silent # 0-coverage). Ждём появления card-ссылок ЯВНО; genuinely-пустой ценовой band → # timeout → 0 offers (корректно). render_wait остаётся коротким settle поверх. try: await page.wait_for_selector("a[href*='/card/']", timeout=12000) except Exception: pass # пустой band или медленный рендер — читаем как есть (0 offers) await asyncio.sleep(random.uniform(*render_wait)) # settle: дорисовать все 20 + title status = resp.status if resp else 0 title = "" try: title = (await page.title()) or "" except Exception: title = "" if status == 403 or _is_block_text(title): raise DomClickBlockedError(f"SERP block status={status} title={title!r}") raw = await page.evaluate(_SERP_IDS_JS) offers: list[dict[str, Any]] = [] if isinstance(raw, list): for item in raw: serp = _serp_offer_to_dict(item) if serp is not None: offers.append(serp) total = _parse_serp_total(title) return offers, total, status # ── Enumerate (mirror domclick.py _sweep_bucket / _paginate, на title-total) ── def _add_offers( offers: list[dict[str, Any]], seen: set[str], done: set[str], out: list[dict[str, Any]], target_new: int | None, ) -> int: """Добавить новые офферы в out (dedup по seen/done). → число добавленных. Бросает _EnumLimitReachedError при достижении target_new.""" added = 0 for serp in offers: sid = serp["id"] if sid in seen or sid in done: continue seen.add(sid) out.append(serp) added += 1 if target_new is not None and len(out) >= target_new: raise _EnumLimitReachedError return added async def _paginate( page: Any, rooms: str, gte: int | None, lte: int | None, total: int | None, seen: set[str], done: set[str], out: list[dict[str, Any]], limit_pages: int, target_new: int | None, render_wait: tuple[float, float], enum_jitter: tuple[float, float], start_page: int, ) -> None: """Пагинация SERP с offset=start_page*20 далее. Стоп: достигли total/cap, кончились limit_pages, или страница не дала новых id.""" bound = OFFSET_CAP if total is None else min(total, OFFSET_CAP) page_idx = start_page while page_idx < limit_pages: offset = page_idx * _SERP_PAGE_SIZE if offset >= bound: break await asyncio.sleep(random.uniform(*enum_jitter)) offers, _, _ = await fetch_serp_page( page, _build_serp_url(rooms, gte, lte, offset), render_wait ) if not offers: break # NB: НЕ break при added==0. При resume первые страницы бакета — это топ-qi # офферы, уже лежащие в `done` → added=0, но новые id есть на след. страницах. # Терминаторы — только пустая страница (выше) и offset>=bound (total/cap). _add_offers(offers, seen, done, out, target_new) page_idx += 1 async def _sweep_bucket( page: Any, rooms: str, gte: int | None, lte: int | None, seen: set[str], done: set[str], out: list[dict[str, Any]], limit_pages: int, target_new: int | None, render_wait: tuple[float, float], enum_jitter: tuple[float, float], depth: int, ) -> None: """offset=0 → total + page-0 офферы. total>cap → price-bisection, иначе пагинируем остаток. total читаем из <title> (чистый фронтовый транспорт).""" offers0, total, _ = await fetch_serp_page( page, _build_serp_url(rooms, gte, lte, 0), render_wait ) _add_offers(offers0, seen, done, out, target_new) if not total: # нет числа / 0 объявлений: офферов на page-0 нет → выходим; есть → допагинируем. if offers0: await _paginate( page, rooms, gte, lte, None, seen, done, out, limit_pages, target_new, render_wait, enum_jitter, 1, ) return if total > OFFSET_CAP and depth < MAX_SPLIT_DEPTH: _gte = gte if gte is not None else 0 _lte = lte if lte is not None else LTE_MAX span = _lte - _gte if span > MIN_PRICE_SPAN: mid = (_gte + _lte) // 2 logger.info( "rooms=%r total=%d > cap — split [%d,%d]+[%d,%d]", rooms, total, _gte, mid, mid + 1, _lte, ) await _sweep_bucket( page, rooms, _gte, mid, seen, done, out, limit_pages, target_new, render_wait, enum_jitter, depth + 1, ) await _sweep_bucket( page, rooms, mid + 1, _lte, seen, done, out, limit_pages, target_new, render_wait, enum_jitter, depth + 1, ) return logger.warning( "rooms=%r price=[%s,%s] total=%d > cap но span<=%d — пагинируем как есть", rooms, gte, lte, total, MIN_PRICE_SPAN, ) await _paginate( page, rooms, gte, lte, total, seen, done, out, limit_pages, target_new, render_wait, enum_jitter, 1, ) async def enumerate_cards( page: Any, done: set[str], limit_pages: int, target_new: int | None, render_wait: tuple[float, float], enum_jitter: tuple[float, float], ) -> list[dict[str, Any]]: """Свод ЕКБ-вторички через фронтовый SERP: все room-бакеты, dedup, skip done.""" out: list[dict[str, Any]] = [] seen: set[str] = set() try: for bucket in ROOM_BUCKETS: logger.info("enumerate: bucket rooms=%r (collected=%d)", bucket, len(out)) await _sweep_bucket( page, bucket, None, None, seen, done, out, limit_pages, target_new, render_wait, enum_jitter, 0, ) except _EnumLimitReachedError: logger.info("enumerate: reached target=%d new cards — stop sweep", target_new) except DomClickBlockedError as exc: logger.error( "enumerate: QRATOR block (%s) — aborting sweep, keeping %d collected", exc, len(out) ) logger.info("enumerate: done, %d new cards collected", len(out)) return out # ── Detail transport ───────────────────────────────────────────────────────── async def fetch_card_html( page: Any, url: str, render_wait: tuple[float, float] ) -> tuple[str, str, int]: """goto → jittered render-wait → re-fetch сырого HTML. (html, title, status).""" resp = await page.goto(url, wait_until="domcontentloaded", timeout=70000) await asyncio.sleep(random.uniform(*render_wait)) title = "" try: title = (await page.title()) or "" except Exception: title = "" html = await page.evaluate(_FETCH_RAW_JS) status = resp.status if resp else 0 return html, title, status # JS: дозабор offer-card v3 (AVM price_prediction + опц. sold_similar) из # контекста УЖЕ загруженной страницы — та же сессия/куки, что прошли QRATOR. _OFFER_CARD_EXTRAS_JS = """async (args) => { const [id, withSold, soldLimit] = args; const out = {}; const get = async (u) => { try { const r = await fetch(u, {credentials: 'include'}); return r.ok ? await r.json() : {__status: r.status}; } catch (e) { return {__err: String(e)}; } }; out.price_prediction = await get( 'https://offer-card.domclick.ru/api/v3/offers/price_prediction?offer_id=' + id ); if (withSold) { out.sold_similar = await get( 'https://offer-card.domclick.ru/api/v3/offers/sold_similar/flat/' + id + '?in_house=false&limit=' + soldLimit + '&offset=0' ); } return out; }""" def _parse_offer_card_avm(price_prediction: Any) -> dict[str, Any] | None: """price_prediction.result → AVM-форма (как d["avm"] из SSR). None если пусто.""" if not isinstance(price_prediction, dict): return None result = price_prediction.get("result") if not isinstance(result, dict): return None market_price = result.get("market_price") if market_price is None: return None avm = { "market_price": market_price, "min": result.get("min_market_price"), "max": result.get("max_market_price"), "repair_quality": result.get("repair_quality"), "rent_long": result.get("rent_long"), "rent_short": result.get("rent_short"), } return {k: v for k, v in avm.items() if v is not None} def _parse_offer_card_sold(sold_similar: Any) -> dict[str, Any] | None: """sold_similar → {total, items}. None если нет result.items.""" if not isinstance(sold_similar, dict): return None result = sold_similar.get("result") if not isinstance(result, dict): return None items = result.get("items") if not isinstance(items, list): return None pagination = sold_similar.get("pagination") or {} total = pagination.get("total") if isinstance(pagination, dict) else None return {"total": total, "items": items} async def fetch_offer_card_extras( page: Any, item_id: str, with_sold_similar: bool, sold_limit: int, ) -> dict[str, Any]: """Дозабор AVM (всегда) + sold_similar (опц.) с offer-card v3 из контекста загруженной страницы. None-safe: любая ошибка/__status/__err → поле None.""" raw = await page.evaluate(_OFFER_CARD_EXTRAS_JS, [str(item_id), with_sold_similar, sold_limit]) if not isinstance(raw, dict): return {"avm": None, "sold_similar": None} avm = _parse_offer_card_avm(raw.get("price_prediction")) sold = _parse_offer_card_sold(raw.get("sold_similar")) if with_sold_similar else None return {"avm": avm, "sold_similar": sold} async def process_card( page: Any, serp: dict[str, Any], render_wait: tuple[float, float], with_sold_similar: bool, sold_limit: int, ) -> tuple[str, dict[str, Any] | None]: """Один detail-fetch с одним ретраем на блок. → (status, detail|None).""" url = serp["source_url"] for attempt in (1, 2): try: html, title, http_status = await fetch_card_html(page, url, render_wait) if http_status == 403 or _is_block_text(title) or _is_block_text(html[:4096]): raise DomClickBlockedError(f"403/challenge title={title!r}") detail = parse_detail_html(html, url) try: extras = await fetch_offer_card_extras( page, serp["id"], with_sold_similar, sold_limit ) if extras.get("avm"): detail["avm"] = extras["avm"] if extras.get("sold_similar"): detail["sold_similar"] = extras["sold_similar"] except Exception as exc: logger.warning("card %s offer-card extras failed: %s", serp["id"], exc) return "ok", detail except DomClickBlockedError as exc: if attempt == 1: wait = random.uniform(45, 90) logger.warning( "card %s blocked (%s) — retry once after %.0fs", serp["id"], exc, wait ) await asyncio.sleep(wait) continue logger.warning("card %s still blocked after retry — status=blocked", serp["id"]) return "blocked", None except DomClickParseError as exc: logger.warning("card %s parse_fail: %s", serp["id"], exc) return "parse_fail", None except Exception as exc: if attempt == 1: logger.warning("card %s fetch error (%s) — retry once", serp["id"], exc) await asyncio.sleep(random.uniform(8, 15)) continue logger.warning("card %s fetch error after retry — parse_fail", serp["id"]) return "parse_fail", None return "parse_fail", None # ── Orchestration ──────────────────────────────────────────────────────────── class Summary: """Счётчики прогона (для финального лога + KeyboardInterrupt flush).""" def __init__(self) -> None: self.enumerated = 0 self.ok = 0 self.blocked = 0 self.parse_fail = 0 self.skipped_done = 0 self.started = datetime.now(UTC) def log_final(self) -> None: elapsed = (datetime.now(UTC) - self.started).total_seconds() logger.info( "SUMMARY enumerated=%d ok=%d blocked=%d parse_fail=%d skipped_done=%d elapsed=%.0fs", self.enumerated, self.ok, self.blocked, self.parse_fail, self.skipped_done, elapsed, ) async def run(args: argparse.Namespace) -> None: out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) done = load_done_ids(out_path) logger.info("resume: %d ids already done in %s", len(done), out_path) summary = Summary() render_wait = (args.render_min, args.render_max) target_new = args.limit if args.limit and args.limit > 0 else None # SERP-навигация требует render-wait на SSR; enum_jitter — доп. пейсинг между # страницами (SERP-навигаций много при свипе, бережём home-IP). enum_render = (max(2.0, args.render_min - 2.0), max(3.0, args.render_max - 2.0)) enum_jitter = (1.5, 4.0) # ВАЖНО: launch(headless=True) = СТАРЫЙ headless → QRATOR 403 даже с дом. IP. # Запускаемся с headless=False, но добавляем --headless=new (новый headless, # окно не видно, детект проходит). --headed → без флага = видимое окно. launch_args = ["--disable-blink-features=AutomationControlled"] if not args.headed: launch_args.append("--headless=new") async with async_playwright() as pw: browser = await pw.chromium.launch(headless=False, args=launch_args) context = await browser.new_context( user_agent=_USER_AGENT, locale="ru-RU", timezone_id="Asia/Yekaterinburg", viewport={"width": 1366, "height": 768}, extra_http_headers={"Accept-Language": "ru-RU,ru;q=0.9"}, ) page = await context.new_page() # Warm-up: устанавливаем QRATOR-куки .domclick.ru перед SERP-навигацией. try: await page.goto(_WARMUP_URL, wait_until="domcontentloaded", timeout=70000) await asyncio.sleep(random.uniform(2.0, 4.0)) logger.info("warmup: loaded %s", _WARMUP_URL) except Exception as exc: logger.warning("warmup navigation failed (%s) — continuing", exc) # 1. Enumerate через фронтовый SERP (тот же чистый браузерный транспорт). # enum-cache: полный список id живёт в файле → следующий запуск ПРОПУСКАЕТ # enumerate (~1.5-2ч) и сразу идёт в detail. detail-resume по `done` # композится поверх: кэш даёт ВСЕ id, `done` (из JSONL) скипает готовые. cards: list[dict[str, Any]] | None = None if args.enum_cache: cards = load_enum_cache(args.enum_cache) if cards is None: cards = await enumerate_cards( page, done, args.limit_pages, target_new, enum_render, enum_jitter ) # Sentinel пишем ТОЛЬКО после реально завершённого enumerate (offers # полный) — прерванный/упавший свод кэш не перезаписывает. if args.enum_cache: write_enum_cache(args.enum_cache, cards) else: logger.info("enum-cache HIT: %d ids — enumerate ПРОПУЩЕН", len(cards)) summary.enumerated = len(cards) if args.enumerate_only: logger.info("enumerate-only: writing %d SERP records (status=enumerated)", len(cards)) for serp in cards: append_record(out_path, build_record(serp, None, "enumerated")) summary.log_final() await context.close() await browser.close() return # 2. Detail. next_break_at = random.randint(25, 40) processed_since_break = 0 try: for idx, serp in enumerate(cards, start=1): if args.limit and (summary.ok + summary.blocked + summary.parse_fail) >= args.limit: break sid = serp["id"] if sid in done: summary.skipped_done += 1 continue status, detail = await process_card( page, serp, render_wait, args.with_sold_similar, args.sold_limit ) rec = build_record(serp, detail, status) append_record(out_path, rec) done.add(sid) if status == "ok": summary.ok += 1 elif status == "blocked": summary.blocked += 1 else: summary.parse_fail += 1 logger.info( "[%d/%d] id=%s status=%s repair=%s living=%s owners=%s price_changes=%d " "| totals ok=%d blocked=%d fail=%d", idx, len(cards), sid, status, rec.get("repair_state"), rec.get("living_area_m2"), rec.get("owners_count"), len(rec.get("price_changes") or []), summary.ok, summary.blocked, summary.parse_fail, ) processed_since_break += 1 if processed_since_break >= next_break_at: brk = random.uniform(args.break_min, args.break_max) logger.info( "human break: sleeping %.0fs after %d cards", brk, processed_since_break ) await asyncio.sleep(brk) processed_since_break = 0 next_break_at = random.randint(25, 40) # Пауза между карточками (главный темп-контроль). await asyncio.sleep(random.uniform(args.min_delay, args.max_delay)) except (KeyboardInterrupt, asyncio.CancelledError): logger.warning("interrupted — flushing summary and exiting gracefully") finally: summary.log_final() await context.close() await browser.close() def build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="DomClick EKB вторичка local runner (home IP, human-paced).", ) p.add_argument("--out", default="domclick_ekb.jsonl", help="JSONL output (append, resumable)") p.add_argument("--limit", type=int, default=None, help="cap total cards (для теста)") p.add_argument("--min-delay", type=float, default=12.0, help="мин. пауза между карточками, с") p.add_argument("--max-delay", type=float, default=30.0, help="макс. пауза между карточками, с") p.add_argument("--render-min", type=float, default=6.0, help="мин. render-wait после goto, с") p.add_argument("--render-max", type=float, default=10.0, help="макс. render-wait после goto, с") p.add_argument("--break-min", type=float, default=40.0, help="мин. длительность human-break, с") p.add_argument( "--break-max", type=float, default=60.0, help="макс. длительность human-break, с" ) p.add_argument("--limit-pages", type=int, default=100, help="макс. страниц на room-бакет") p.add_argument("--enumerate-only", action="store_true", help="только перечислить, без detail") p.add_argument( "--enum-cache", type=str, default=None, help="файл-кэш полного списка id (enumerate один раз → resume detail поверх)", ) p.add_argument( "--with-sold-similar", action="store_true", help="дозабор sold_similar с offer-card v3 (+1 запрос/карточку)", ) p.add_argument( "--sold-limit", type=int, default=5, help="limit для sold_similar (если включён)" ) p.add_argument( "--headed", action="store_true", help="видимое окно браузера (debug); по умолчанию new-headless (окно скрыто)", ) return p def main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S", ) args = build_arg_parser().parse_args() if args.max_delay < args.min_delay: args.max_delay = args.min_delay if args.render_max < args.render_min: args.render_max = args.render_min # Graceful Ctrl+C: asyncio.run превратит KeyboardInterrupt в CancelledError # внутри run(); финальный flush — в finally. try: asyncio.run(run(args)) except KeyboardInterrupt: logger.warning("KeyboardInterrupt at top level — exiting") if __name__ == "__main__": main()