gendesign/tradein-mvp/backend/scripts/domclick_local_runner.py
bot-backend 480ecc90e5 feat(tradein/domclick): local Layer-B runner — home-IP + new-headless chromium, paced
Self-contained script (playwright chromium, NO app imports) для сбора DomClick EKB
detail с домашнего residential IP (обходит QRATOR — verified live: 2 карточки ok,
repair/areas/priceHistory/egrn/owners собраны). Enumerate via bff (Layer A) + card
__SSR_STATE__ parse (Layer B) → JSONL (resume/idempotent).

KEY: дифференциатор — РЕЖИМ HEADLESS, не только IP. С того же IP старый headless=True
→ 403, new-headless → 200 + SSR. Раннер дефолтит на new-headless.

Пейсинг: рандом 12-30с между карточками, render-jitter 6-10с, human-break каждые
25-40 (60-180с), 1 retry на 403. ~100-150 карточек/час, full ~6400 ≈ 45-65ч.
JSONL ингестится в tradein DB отдельным шагом. НЕ deploy — локальный ops-инструмент.
2026-06-27 18:24:08 +03:00

1194 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 (BFF 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 (BFF JSON) — ``context.request.get(url)`` (тот же сетевой
стек/IP/куки браузера) → парсим первый ``{`` … последний ``}``;
• detail — ``page.goto(card_url)`` → jittered wait → ``page.evaluate`` с
``fetch(location.href)`` чтобы получить СЫРОЙ HTML (SPA удаляет
``window.__SSR_STATE__`` после гидрации, поэтому нужен повторный fetch).
ВАЖНО — РЕЖИМ ОКНА (verified 2026-06-27 на домашнем IP этой машины)
------------------------------------------------------------------
QRATOR ДЕТЕКТИТ СТАРЫЙ HEADLESS-РЕЖИМ: ``launch(headless=True)`` → даже с
домашнего IP карточка отдаёт ``401 / 403 | Домклик`` (нет ``__SSR_STATE__``).
А вот НОВЫЙ headless (``--headless=new``) И обычный headed → ``200`` + полный
``__SSR_STATE__`` (~330 КБ). Поэтому по умолчанию запускаемся в new-headless
(окно НЕ видно, но детект проходит). ``--headed`` → видимое окно (debug).
BFF-enumerate проходит в ЛЮБОМ режиме (это не браузерный рендер).
ВАЖНО — ЧЕЛОВЕКОПОДОБНЫЙ ТЕМП (явное требование оператора)
=========================================================
Домашний 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) ──────────────────
_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"
_CARD_HOST_FALLBACK = "https://ekaterinburg.domclick.ru"
_WARMUP_URL = "https://ekaterinburg.domclick.ru/"
# Порядок бакетов комнат влияет на логи. urlencode("5+") → "5%2B".
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
OFFSET_CAP: int = 2000 # макс. offset, принимаемый BFF
PAGE_SIZE: int = 20 # items на страницу (жёстко задан API)
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)
# Гео-guard ЕКБ (bbox). aids=20561 уже даёт чистый ЕКБ; bbox — defensive.
# Deviation от app: ослаблено строгое равенство offerRegionName до bbox-only
# (устойчивее к дрейфу имени региона; aids уже ограничивает выборку ЕКБ).
_EKB_LAT_MIN, _EKB_LAT_MAX = 56.6, 57.0
_EKB_LON_MIN, _EKB_LON_MAX = 60.2, 60.9
# Маркеры 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(); }"
)
# ── Исключения ───────────────────────────────────────────────────────────────
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
# ── BFF JSON extraction (инлайн из domclick.py) ──────────────────────────────
def _is_block_text(text: str) -> bool:
low = text.lower()
return any(m in low for m in _BLOCK_MARKERS)
def _extract_json(body: str) -> dict[str, Any]:
"""Первый ``{`` … последний ``}`` из ответа BFF (raw JSON или <pre>-wrap).
Raises:
DomClickBlockedError: QRATOR/captcha маркеры в теле.
ValueError: JSON не найден / не dict.
"""
if _is_block_text(body):
raise DomClickBlockedError("BFF: QRATOR block page detected")
start = body.find("{")
end = body.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object in BFF response (len={len(body)})")
data = json.loads(body[start : end + 1])
if not isinstance(data, dict):
raise ValueError(f"BFF JSON is not a dict: {type(data)}")
return data
def _build_offers_url(rooms: str, gte: int | None, lte: int | None, offset: int) -> str:
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
("rooms", rooms),
("sort", "qi"),
("sort_dir", "desc"),
("offset", str(offset)),
("limit", str(PAGE_SIZE)),
]
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"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
def _build_count_url(rooms: str, gte: int | None, lte: int | None) -> str:
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
("rooms", rooms),
("sort", "qi"),
("sort_dir", "desc"),
]
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"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
def _is_geo_ok(item: dict[str, Any]) -> bool:
"""bbox-guard ЕКБ. lat/lon обязательны и в пределах bbox."""
loc = item.get("location") or {}
lat = loc.get("lat")
lon = loc.get("lon")
if lat is None or lon is None:
return False
try:
latf, lonf = float(lat), float(lon)
except (ValueError, TypeError):
return False
return _EKB_LAT_MIN <= latf <= _EKB_LAT_MAX and _EKB_LON_MIN <= lonf <= _EKB_LON_MAX
# ── SERP item → плоский dict (инлайн из domclick.py _map_item) ────────────────
def map_serp_item(item: dict[str, Any], force_rooms: int | None) -> dict[str, Any] | None:
"""offer-item из BFF → SERP-dict. None при price<=0 / без id / ошибке."""
try:
price = item.get("price") or 0
if price <= 0:
return None
item_id = item.get("id")
if item_id is None:
return None
sid = str(item_id)
path = item.get("path") or ""
if path.startswith("/"):
path = _CARD_HOST_FALLBACK + path
loc = item.get("location") or {}
oi = item.get("objectInfo") or {}
house = item.get("house") or {}
addr = item.get("address") or {}
area_raw = oi.get("area")
floor_raw = oi.get("floor")
floors_raw = house.get("floors")
year_raw = house.get("buildYear")
sq_raw = item.get("squarePrice")
if force_rooms is not None:
rooms: int | None = force_rooms
else:
rr = oi.get("rooms")
rooms = int(rr) if rr is not None else None
return {
"id": sid,
"source_url": path,
"price_rub": int(price),
"square_price": int(sq_raw) if sq_raw else None,
"area_m2": float(area_raw) if area_raw else None,
"rooms": rooms,
"floor": int(floor_raw) if floor_raw else None,
"total_floors": int(floors_raw) if floors_raw else None,
"year_built": int(year_raw) if year_raw else None,
"lat": loc.get("lat"),
"lon": loc.get("lon"),
"address": addr.get("displayName"),
"is_apartment": oi.get("isApartment"),
"offer_region_name": item.get("offerRegionName"),
"description": item.get("description"),
"is_sber_collateral": item.get("isSberCollateral"),
"published_date": item.get("publishedDate"),
}
except Exception:
logger.warning("map_serp_item failed id=%r", item.get("id"), exc_info=True)
return None
# ── 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"))
# 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 {
"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-инференс из SERP-описания (как в проде для пустого структурного)
repair_state = infer_repair_state_from_text(serp.get("description"))
is_sber = serp.get("is_sber_collateral")
if not is_sber and d.get("collateral_sber"):
is_sber = True
return {
"id": serp.get("id"),
"source_url": serp.get("source_url"),
"status": status,
"fetched_at": datetime.now(UTC).isoformat(),
"price_rub": serp.get("price_rub"),
"area_m2": serp.get("area_m2"),
"rooms": serp.get("rooms"),
"floor": serp.get("floor"),
"total_floors": serp.get("total_floors"),
"year_built": d.get("year_built") or serp.get("year_built"),
"lat": serp.get("lat"),
"lon": serp.get("lon"),
"address": 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": serp.get("square_price"),
"is_apartment": serp.get("is_apartment"),
"offer_region_name": serp.get("offer_region_name"),
"published_date": serp.get("published_date"),
**(d.get("raw_extra") or {}),
},
}
# ── 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()
# ── BFF transport (playwright APIRequestContext) ─────────────────────────────
async def fetch_bff_json(context: Any, url: str) -> dict[str, Any]:
"""GET BFF через context.request (тот же IP/куки браузера) → dict."""
resp = await context.request.get(
url,
headers={
"Accept": "application/json, text/plain, */*",
"Referer": "https://ekaterinburg.domclick.ru/search?deal_type=sale",
"Accept-Language": "ru-RU,ru;q=0.9",
},
timeout=60000,
)
body = await resp.text()
if resp.status == 403:
raise DomClickBlockedError(f"BFF 403 for {url}")
return _extract_json(body)
async def get_count(context: Any, rooms: str, gte: int | None, lte: int | None) -> int:
data = await fetch_bff_json(context, _build_count_url(rooms, gte, lte))
res = data.get("result") or {}
raw = res.get("snippetsCount")
return int(raw) if raw else 0
# ── Enumerate (mirror domclick.py _sweep_bucket / _paginate) ─────────────────
async def _paginate(
context: 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,
enum_jitter: tuple[float, float],
) -> None:
force_rooms = 0 if rooms == "st" else None
page_idx = 0
while page_idx < limit_pages:
offset = page_idx * PAGE_SIZE
if offset >= OFFSET_CAP:
break
data = await fetch_bff_json(context, _build_offers_url(rooms, gte, lte, offset))
items = (data.get("result") or {}).get("items") or []
if not items:
break
for item in items:
serp = map_serp_item(item, force_rooms)
if serp is None:
continue
if not _is_geo_ok(item):
continue
sid = serp["id"]
if sid in seen or sid in done:
continue
seen.add(sid)
out.append(serp)
if target_new is not None and len(out) >= target_new:
raise _EnumLimitReachedError
page_idx += 1
if len(items) < PAGE_SIZE:
break
await asyncio.sleep(random.uniform(*enum_jitter))
async def _sweep_bucket(
context: 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,
enum_jitter: tuple[float, float],
depth: int,
) -> None:
count = await get_count(context, rooms, gte, lte)
if count == 0:
return
if count > 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:
logger.warning(
"rooms=%r price=[%s,%s] count=%d > cap but span<=%d — paginating as-is",
rooms,
gte,
lte,
count,
MIN_PRICE_SPAN,
)
await _paginate(
context, rooms, gte, lte, seen, done, out, limit_pages, target_new, enum_jitter
)
return
mid = (_gte + _lte) // 2
logger.info(
"rooms=%r count=%d > cap — split [%d,%d]+[%d,%d]",
rooms,
count,
_gte,
mid,
mid + 1,
_lte,
)
await _sweep_bucket(
context,
rooms,
_gte,
mid,
seen,
done,
out,
limit_pages,
target_new,
enum_jitter,
depth + 1,
)
await _sweep_bucket(
context,
rooms,
mid + 1,
_lte,
seen,
done,
out,
limit_pages,
target_new,
enum_jitter,
depth + 1,
)
else:
await _paginate(
context, rooms, gte, lte, seen, done, out, limit_pages, target_new, enum_jitter
)
async def enumerate_cards(
context: Any,
done: set[str],
limit_pages: int,
target_new: int | None,
enum_jitter: tuple[float, float],
) -> list[dict[str, Any]]:
"""Свод ЕКБ-вторички через BFF: все 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(
context,
bucket,
None,
None,
seen,
done,
out,
limit_pages,
target_new,
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
async def process_card(
page: Any,
serp: dict[str, Any],
render_wait: tuple[float, float],
) -> 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)
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
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 перед BFF-запросами.
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.
cards = await enumerate_cards(context, done, args.limit_pages, target_new, enum_jitter)
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)
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(60, 180)
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("--limit-pages", type=int, default=100, help="макс. страниц на room-бакет")
p.add_argument("--enumerate-only", action="store_true", help="только перечислить, без detail")
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()