gendesign/tradein-mvp/backend/scripts/domclick_local_runner.py
bot-backend 43b392b809
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/domclick): local-runner + ingest tooling → main (split from parked Layer-B)
Выделяет МЕРДЖАБЕЛЬНУЮ часть DomClick-тулинга из запаркованного PR #2005
(BLOCKED #2000 — прод Layer-B скрейпер через прокси режет QRATOR-403):

- scripts/domclick_local_runner.py — ручной offline-сборщик с домашнего IP:
  SERP-enumerate (фронт, BFF-JSON режет QRATOR) + card __SSR_STATE__ +
  offer-card v3 (AVM price_prediction + sold_similar). --enum-cache (enumerate
  1 раз → файл с sentinel → следующий запуск грузит кэш и пропускает enumerate),
  wait_for_selector (SERP-карточки догидрируются), resume по id. Self-contained.
- scripts/ingest_domclick_jsonl.py — JSONL → trade-in БД (save_listings +
  house-match в SAVEPOINT + save_detail_enrichment, идемпотентно, --limit/--dry-run).
- app/services/scrapers/domclick_detail.py + domclick_exceptions.py — card-SSR
  parse + save_detail_enrichment (нужны ингесту). Импортятся ТОЛЬКО ингестом
  (operator-run); прод-runtime их не дёргает (dormant в образе).
- tests/scrapers/test_domclick_detail.py + обновлённый scripts/README.md.

НЕ включает прод Layer-B (tasks/domclick_detail_backfill.py, scheduler-расписание,
data/sql/139_*) — остаётся запаркован на PR #2005 до чистого прокси (#2000).
БД-схема не меняется (detail-колонки уже в проде, миграции нет).

Live-validated: раннер качает ЕКБ-вторичку прямо сейчас (600+ карт, 0 блоков),
ингест залил их в прод-БД (house_matched, идемпотентно).
2026-06-28 10:26:52 +03:00

1430 lines
58 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 (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 —
из ``<title>`` ("… 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()