feat(tradein/domclick-local): SERP-enumerate (QRATOR-обход) + offer-card v3 extras
(а) enumerate: прямой bff-search offers/v1+count/v1 режет QRATOR-403 с home-IP →
переведён на навигацию по фронтовому SERP (page.goto, тот же чистый транспорт что
card-detail): id из DOM (a[href*=/card/]), total из <title>, пагинация offset=20,
price-bisection при total>2000 ("5+"→5%2B через urlencode). Layer-A
(price/area/rooms/floor/total_floors/lat/lon/address) теперь из card-SSR
(productCard.priceInfo/objectInfo + address.position + houseInfo.info), т.к.
enumerate отдаёт только id+url. Удалены мёртвые BFF-функции. Схема JSONL не изменилась.
(б) detail: дозабор с offer-card v3 (200 с home-IP, в отличие от прод-прокси):
price_prediction (AVM заполненный + repair_quality + аренда, всегда) и sold_similar
(опц., --with-sold-similar/--sold-limit) через page.evaluate из QRATOR-сессии карточки.
XHR-AVM переопределяет пустой SSR pricePrediction; sold_similar в raw_extra.
Live-tested: enum-only собрал ids без QRATOR-аборта; full --limit 2 → price_rub
6.1M/3.1M, area/floor/coords/address из card-SSR, AVM market_price 4.99M/3.01M,
sold_similar.total≈1691. ruff clean.
This commit is contained in:
parent
f7426dc12b
commit
9b1f064069
1 changed files with 399 additions and 254 deletions
|
|
@ -14,28 +14,36 @@ DomClick фронтит QRATOR anti-bot. Проверено вживую (2026-0
|
|||
|
||||
САМОДОСТАТОЧНОСТЬ
|
||||
=================
|
||||
Никаких ``app.*`` импортов: вся логика Layer A (BFF enumerate) и Layer B
|
||||
Никаких ``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 (BFF JSON) — ``context.request.get(url)`` (тот же сетевой
|
||||
стек/IP/куки браузера) → парсим первый ``{`` … последний ``}``;
|
||||
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 карточка отдаёт ``401 / 403 | Домклик`` (нет ``__SSR_STATE__``).
|
||||
А вот НОВЫЙ headless (``--headless=new``) И обычный headed → ``200`` + полный
|
||||
``__SSR_STATE__`` (~330 КБ). Поэтому по умолчанию запускаемся в new-headless
|
||||
домашнего IP карточка/SERP отдаёт ``401 / 403 | Домклик`` (нет
|
||||
``__SSR_STATE__``). А вот НОВЫЙ headless (``--headless=new``) И обычный headed →
|
||||
``200`` + полный рендер. Поэтому по умолчанию запускаемся в new-headless
|
||||
(окно НЕ видно, но детект проходит). ``--headed`` → видимое окно (debug).
|
||||
BFF-enumerate проходит в ЛЮБОМ режиме (это не браузерный рендер).
|
||||
|
||||
ВАЖНО — ЧЕЛОВЕКОПОДОБНЫЙ ТЕМП (явное требование оператора)
|
||||
=========================================================
|
||||
|
|
@ -93,26 +101,26 @@ 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"
|
||||
_EKB_AREA_ID = "20561" # aids= фильтр ЕКБ-вторички на фронтовом SERP
|
||||
_SERP_HOST = "https://ekaterinburg.domclick.ru"
|
||||
_WARMUP_URL = "https://ekaterinburg.domclick.ru/"
|
||||
|
||||
# Порядок бакетов комнат влияет на логи. urlencode("5+") → "5%2B".
|
||||
# Порядок бакетов комнат влияет на логи. КРИТИЧНО: "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, принимаемый BFF
|
||||
PAGE_SIZE: int = 20 # items на страницу (жёстко задан API)
|
||||
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)
|
||||
|
||||
# Гео-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
|
||||
# Гео-фильтр ЕКБ обеспечивает aids=20561 в SERP-URL; bbox-guard убран вместе с
|
||||
# BFF-enumerate (фронт отдаёт только id+url, координаты — из card-SSR).
|
||||
|
||||
# Маркеры QRATOR / anti-bot challenge.
|
||||
_BLOCK_MARKERS: tuple[str, ...] = (
|
||||
|
|
@ -142,6 +150,17 @@ _FETCH_RAW_JS = (
|
|||
"{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))"
|
||||
)
|
||||
|
||||
|
||||
# ── Исключения ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -360,7 +379,7 @@ def _extract_item_id(url: str) -> str | None:
|
|||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# ── BFF JSON extraction (инлайн из domclick.py) ──────────────────────────────
|
||||
# ── Block detection / SERP URL & parsing ─────────────────────────────────────
|
||||
|
||||
|
||||
def _is_block_text(text: str) -> bool:
|
||||
|
|
@ -368,134 +387,55 @@ def _is_block_text(text: str) -> bool:
|
|||
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:
|
||||
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]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
("rooms", rooms),
|
||||
("sort", "qi"),
|
||||
("sort_dir", "desc"),
|
||||
("aids", _EKB_AREA_ID),
|
||||
("from", "topline2020"),
|
||||
("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)}"
|
||||
return f"{_SERP_HOST}/search?{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)
|
||||
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) ──────────────────────
|
||||
|
|
@ -601,6 +541,27 @@ def parse_detail_html(html: str, source_url: str) -> dict[str, Any]:
|
|||
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]] = []
|
||||
|
|
@ -664,6 +625,19 @@ def parse_detail_html(html: str, source_url: str) -> dict[str, Any]:
|
|||
)
|
||||
|
||||
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,
|
||||
|
|
@ -698,27 +672,29 @@ def build_record(
|
|||
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"))
|
||||
# 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": serp.get("price_rub"),
|
||||
"area_m2": serp.get("area_m2"),
|
||||
"rooms": serp.get("rooms"),
|
||||
"floor": serp.get("floor"),
|
||||
"total_floors": serp.get("total_floors"),
|
||||
"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": serp.get("lat"),
|
||||
"lon": serp.get("lon"),
|
||||
"address": serp.get("address"),
|
||||
"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"),
|
||||
|
|
@ -734,11 +710,16 @@ def build_record(
|
|||
"is_sber_collateral": is_sber,
|
||||
"avm": d.get("avm") or {},
|
||||
"raw_extra": {
|
||||
"square_price": serp.get("square_price"),
|
||||
"is_apartment": serp.get("is_apartment"),
|
||||
"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 {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -774,79 +755,103 @@ def append_record(path: Path, rec: dict[str, Any]) -> None:
|
|||
fh.flush()
|
||||
|
||||
|
||||
# ── BFF transport (playwright APIRequestContext) ─────────────────────────────
|
||||
# ── SERP transport (playwright page navigation, чистый браузерный транспорт) ──
|
||||
|
||||
|
||||
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 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)
|
||||
await asyncio.sleep(random.uniform(*render_wait)) # дать SSR отрендериться
|
||||
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
|
||||
|
||||
|
||||
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, на title-total) ──
|
||||
|
||||
|
||||
# ── Enumerate (mirror domclick.py _sweep_bucket / _paginate) ─────────────────
|
||||
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(
|
||||
context: Any,
|
||||
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:
|
||||
force_rooms = 0 if rooms == "st" else None
|
||||
page_idx = 0
|
||||
"""Пагинация 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 * 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:
|
||||
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
|
||||
added = _add_offers(offers, seen, done, out, target_new)
|
||||
page_idx += 1
|
||||
if added == 0:
|
||||
break
|
||||
|
||||
|
||||
async def _sweep_bucket(
|
||||
context: Any,
|
||||
page: Any,
|
||||
rooms: str,
|
||||
gte: int | None,
|
||||
lte: int | None,
|
||||
|
|
@ -855,86 +860,120 @@ async def _sweep_bucket(
|
|||
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:
|
||||
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",
|
||||
"""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,
|
||||
count,
|
||||
MIN_PRICE_SPAN,
|
||||
None,
|
||||
seen,
|
||||
done,
|
||||
out,
|
||||
limit_pages,
|
||||
target_new,
|
||||
render_wait,
|
||||
enum_jitter,
|
||||
1,
|
||||
)
|
||||
await _paginate(
|
||||
context, rooms, gte, lte, seen, done, out, limit_pages, target_new, enum_jitter
|
||||
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
|
||||
mid = (_gte + _lte) // 2
|
||||
logger.info(
|
||||
"rooms=%r count=%d > cap — split [%d,%d]+[%d,%d]",
|
||||
logger.warning(
|
||||
"rooms=%r price=[%s,%s] total=%d > cap но span<=%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
|
||||
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(
|
||||
context: Any,
|
||||
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]]:
|
||||
"""Свод ЕКБ-вторички через BFF: все room-бакеты, dedup, skip done."""
|
||||
"""Свод ЕКБ-вторички через фронтовый 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(
|
||||
context,
|
||||
page,
|
||||
bucket,
|
||||
None,
|
||||
None,
|
||||
|
|
@ -943,6 +982,7 @@ async def enumerate_cards(
|
|||
out,
|
||||
limit_pages,
|
||||
target_new,
|
||||
render_wait,
|
||||
enum_jitter,
|
||||
0,
|
||||
)
|
||||
|
|
@ -975,10 +1015,90 @@ async def fetch_card_html(
|
|||
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"]
|
||||
|
|
@ -988,6 +1108,16 @@ async def process_card(
|
|||
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:
|
||||
|
|
@ -1049,6 +1179,9 @@ async def run(args: argparse.Namespace) -> None:
|
|||
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.
|
||||
|
|
@ -1069,7 +1202,7 @@ async def run(args: argparse.Namespace) -> None:
|
|||
)
|
||||
page = await context.new_page()
|
||||
|
||||
# Warm-up: устанавливаем QRATOR-куки .domclick.ru перед BFF-запросами.
|
||||
# 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))
|
||||
|
|
@ -1077,8 +1210,10 @@ async def run(args: argparse.Namespace) -> None:
|
|||
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)
|
||||
# 1. Enumerate через фронтовый SERP (тот же чистый браузерный транспорт).
|
||||
cards = await enumerate_cards(
|
||||
page, done, args.limit_pages, target_new, enum_render, enum_jitter
|
||||
)
|
||||
summary.enumerated = len(cards)
|
||||
|
||||
if args.enumerate_only:
|
||||
|
|
@ -1102,7 +1237,9 @@ async def run(args: argparse.Namespace) -> None:
|
|||
summary.skipped_done += 1
|
||||
continue
|
||||
|
||||
status, detail = await process_card(page, serp, render_wait)
|
||||
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)
|
||||
|
|
@ -1166,6 +1303,14 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
p.add_argument("--limit-pages", type=int, default=100, help="макс. страниц на room-бакет")
|
||||
p.add_argument("--enumerate-only", action="store_true", help="только перечислить, без 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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue