ДомКлик — четвёртая площадка ручного сбора Москвы #3496
1 changed files with 257 additions and 2 deletions
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1).
|
||||
"""Локальный ручной сборщик SERP Авито/Циан/Яндекса/DomClick по Москве и МО (эпик #2989, трек 1).
|
||||
|
||||
Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
|
||||
прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
|
||||
|
|
@ -59,6 +59,21 @@ from scraper_kit.providers.yandex.serp import ( # noqa: E402
|
|||
_parse_gate_json,
|
||||
)
|
||||
|
||||
# DomClick — тоже JSON BFF-ответ (Layer A прод-скрейпера), а не HTML SERP, как
|
||||
# и Яндекс выше. Берём из кита чистую функцию извлечения JSON (_extract_json)
|
||||
# и сам класс DomClickScraper — не ради транспорта (тот же приём с браузером
|
||||
# владельца), а ради DomClickScraper._map_item: маппинг offer-item → ScrapedLot
|
||||
# дублировать своим кодом было бы регрессивно. Маркеры блока — единый источник
|
||||
# Layer A/B кита, см. докстринг DOMCLICK_BLOCK_MARKERS.
|
||||
from scraper_kit.domclick_exceptions import ( # noqa: E402
|
||||
DOMCLICK_BLOCK_MARKERS,
|
||||
DomClickBlockedError,
|
||||
)
|
||||
from scraper_kit.providers.domclick.serp import ( # noqa: E402
|
||||
DomClickScraper,
|
||||
_extract_json,
|
||||
)
|
||||
|
||||
# Вкладка, открытая у владельца: вторичка, Москва + МО.
|
||||
DEFAULT_AVITO_BASE_URL = (
|
||||
"https://www.avito.ru/moskva_i_mo/kvartiry/prodam/vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
||||
|
|
@ -138,6 +153,37 @@ YANDEX_MAX_PAGES = 25
|
|||
# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
|
||||
YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES # 500
|
||||
|
||||
# DomClick: JSON BFF listing API, Москва (эпик #2989, трек 1).
|
||||
#
|
||||
# GUID Москвы проверен живым запросом 12.09: и region, и locality — один и тот
|
||||
# же 1d1463ae-c80f-4d19-9331-a1b68a85b553 (для сравнения ЕКБ —
|
||||
# 0d475b79-88de-4054-818c-37d8f9d0d440). Параметр aids (у ЕКБ 20561, сужает
|
||||
# выдачу до конкретного агрегатора) для Москвы НЕ нужен — без него счётчик
|
||||
# листинга точно совпадает с сайтом.
|
||||
MSK_DOMCLICK_GUID = "1d1463ae-c80f-4d19-9331-a1b68a85b553"
|
||||
|
||||
DEFAULT_DOMCLICK_BASE_URL = (
|
||||
"https://bff-search-web.domclick.ru/api/offers/v1"
|
||||
f"?address={MSK_DOMCLICK_GUID}&deal_type=sale&category=living&offer_type=flat"
|
||||
"&sort=qi&sort_dir=desc&limit=20&offset=0"
|
||||
)
|
||||
|
||||
# Замерено живым запросом 12.09, не из документации: offset=1980 отдаёт полную
|
||||
# страницу (20 items), offset=2000 отвечает HTTP 400
|
||||
# {"statusCode":400,"error":"Bad Request"}. Значит жёсткий потолок пагинации —
|
||||
# 100 страниц по 20 штук = 2000 офферов на один набор фильтров.
|
||||
DOMCLICK_PAGE_SIZE = 20
|
||||
DOMCLICK_MAX_PAGES = 100
|
||||
|
||||
# bbox Москвы с ТиНАО — единственный надёжный гео-гард для DomClick.
|
||||
# offerRegionName использовать НЕЛЬЗЯ: часть офферов Новой Москвы приходит с
|
||||
# именами вида "г. Говорово", а не "Москва" — гард по имени региона молча
|
||||
# вырезал бы легитимные лоты. scraper._is_geo_ok() кита сюда тоже не подходит:
|
||||
# он захардкожен на offerRegionName == "Екатеринбург" и отбросил бы буквально
|
||||
# всю московскую выдачу.
|
||||
_MSK_LAT_MIN, _MSK_LAT_MAX = 55.14, 56.02
|
||||
_MSK_LON_MIN, _MSK_LON_MAX = 36.80, 37.97
|
||||
|
||||
PRICE_FLOOR = 500_000 # нижняя граница осмысленного коридора, ₽
|
||||
PRICE_PROBE_START = 8_000_000 # старт удвоения при поиске верхней границы
|
||||
PRICE_CEIL = 2_000_000_000
|
||||
|
|
@ -528,6 +574,197 @@ async def _yandex_wait_ready(page: Any) -> None:
|
|||
pass
|
||||
|
||||
|
||||
# --- DomClick: JSON BFF (конвейер как у Яндекса — payload вместо DOM) ---------
|
||||
|
||||
|
||||
def _domclick_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
|
||||
"""URL коридора DomClick: sale_price__gte/__lte + offset (аналог _cian_build_url).
|
||||
|
||||
page — 1-based, как у остальных платформ; offset у BFF — 0-based, поэтому
|
||||
offset = (page - 1) * DOMCLICK_PAGE_SIZE. offset проставляется ВСЕГДА, в
|
||||
т.ч. на первой странице (как priceMin/priceMax/page у Яндекса): старые
|
||||
offset/sale_price__gte/sale_price__lte из base_url выкидываются и ставятся
|
||||
заново, дублей ключей не остаётся.
|
||||
"""
|
||||
parts = urlsplit(base_url)
|
||||
q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
||||
if k not in {"offset", "sale_price__gte", "sale_price__lte"}]
|
||||
if lo is not None:
|
||||
q.append(("sale_price__gte", str(int(lo))))
|
||||
if hi is not None:
|
||||
q.append(("sale_price__lte", str(int(hi))))
|
||||
q.append(("offset", str((page - 1) * DOMCLICK_PAGE_SIZE)))
|
||||
return urlunsplit(
|
||||
(parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
|
||||
)
|
||||
|
||||
|
||||
def _unescape_pre_entities(html: str) -> str:
|
||||
"""Снять HTML-экранирование текстового узла <pre>, в который Chrome заворачивает
|
||||
ответ ручки.
|
||||
|
||||
`page.content()` отдаёт СЕРИАЛИЗОВАННЫЙ документ, а не тело ответа: сериализатор
|
||||
экранирует в текстовом узле ровно четыре вещи — `&`, `<`, `>` и неразрывный
|
||||
пробел. Синтаксис JSON от этого не страдает (`&` внутри строки — валидная
|
||||
строка), поэтому дефект тихий: `json.loads` проходит, а в описании и адресе
|
||||
вместо «&» и неразрывного пробела оседают `&` и ` `. Замер на живой
|
||||
выдаче Москвы: 29 ` ` на одной странице из двадцати карточек.
|
||||
|
||||
Кавычки в список не входят намеренно: сериализатор экранирует их только в
|
||||
значениях атрибутов, а в текстовом узле `"` остаётся собой — и это важно, иначе
|
||||
`\"` внутри JSON-строки превратился бы в невалидный escape и уронил разбор
|
||||
страницы целиком.
|
||||
|
||||
Порядок фиксирован: `&` разворачивается ПОСЛЕДНИМ, иначе уже развёрнутый
|
||||
амперсанд склеится со следующей последовательностью и даст второй разбор.
|
||||
"""
|
||||
return (
|
||||
html.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
)
|
||||
|
||||
|
||||
def _domclick_payload(scraper: Any, html: str) -> dict[str, Any] | None:
|
||||
"""JSON BFF-ответ из содержимого вкладки, с мемоизацией на один HTML.
|
||||
|
||||
parse_page дёргает extract_total_count и parse_cards по одному и тому же
|
||||
html — разбираем один раз (тот же приём, что и _yandex_payload). Парсер не
|
||||
свой: _extract_json — та же функция, которой пользуется прод-скрейпер
|
||||
(providers/domclick/serp.py), со своим корректным порядком «сначала JSON,
|
||||
потом маркеры блока» (#3267 — маркер может встретиться в тексте самого
|
||||
объявления, подстрочный поиск ДО попытки распарсить JSON уже забанил живой
|
||||
узел на 6 часов в проде 30.08).
|
||||
|
||||
К моменту вызова этой функции _guard уже прогнал тот же html через
|
||||
_domclick_detect_block, и настоящий блок остановил бы сбор раньше —
|
||||
DomClickBlockedError/ValueError здесь только тихо превращаются в None, как
|
||||
и в yandex-ветке.
|
||||
"""
|
||||
if getattr(scraper, "_payload_html", None) is html:
|
||||
return scraper._payload
|
||||
try:
|
||||
payload: dict[str, Any] | None = _extract_json(_unescape_pre_entities(html))
|
||||
except (DomClickBlockedError, ValueError):
|
||||
payload = None
|
||||
scraper._payload_html = html
|
||||
scraper._payload = payload
|
||||
return payload
|
||||
|
||||
|
||||
def _domclick_extract_total(scraper: Any, html: str) -> int | None:
|
||||
"""result.pagination.total — а НЕ отдельная count-ручка.
|
||||
|
||||
Замер живым запросом 12.09: pagination.total по Москве без фильтра комнат
|
||||
= 23692, и это число реально достижимо постраничным обходом (offset до
|
||||
1980, см. DOMCLICK_MAX_PAGES). У отдельной count-ручки другое число —
|
||||
25898: оно включает дубли одного объекта у разных агентств, которые
|
||||
пагинацией физически недостижимы. Если бы бисекция целилась в
|
||||
count-ручку, она считала бы коридоры «недобранными» там, где добирать
|
||||
нечего, и truncated/missed в notes врали бы.
|
||||
"""
|
||||
payload = _domclick_payload(scraper, html)
|
||||
if not payload:
|
||||
return None
|
||||
result = payload.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
pagination = result.get("pagination")
|
||||
if not isinstance(pagination, dict):
|
||||
return None
|
||||
total = pagination.get("total")
|
||||
return int(total) if total is not None else None
|
||||
|
||||
|
||||
def _domclick_parse_cards(scraper: Any, html: str) -> list[Any]:
|
||||
"""result.items → ScrapedLot через DomClickScraper._map_item (кит, без своего маппинга).
|
||||
|
||||
Гео-гард — СВОЙ, по bbox Москвы+ТиНАО, а не scraper._is_geo_ok(): тот метод
|
||||
кита захардкожен на offerRegionName == "Екатеринбург" и отбросил бы всю
|
||||
московскую выдачу целиком. offerRegionName как критерий тоже не годится:
|
||||
часть офферов Новой Москвы приходит с именами вида "г. Говорово", а не
|
||||
"Москва" — гард по названию региона молча резал бы легитимные лоты.
|
||||
Координаты — единственный признак, который не врёт.
|
||||
|
||||
last_raw_count фиксируется ДО геогарда (как raw-счётчик у Циан) — иначе
|
||||
страница, где все 20 items легитимно оказались вне bbox, была бы
|
||||
неотличима от блока/пустого ответа в parse_page.
|
||||
"""
|
||||
payload = _domclick_payload(scraper, html)
|
||||
scraper.last_raw_count = 0
|
||||
if not payload:
|
||||
return []
|
||||
result = payload.get("result")
|
||||
items = result.get("items") if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
scraper.last_raw_count = len(items)
|
||||
lots = []
|
||||
for item in items:
|
||||
loc = item.get("location") or {}
|
||||
# float() в try: прогон идёт часами без присмотра, и один оффер с мусором
|
||||
# в координате уронил бы ВЕСЬ коллектор на середине коридора вместо того,
|
||||
# чтобы выпасть одной карточкой. Нечисловая координата = лот без гео,
|
||||
# пропускаем его так же, как отсутствующую.
|
||||
try:
|
||||
lat = float(loc["lat"])
|
||||
lon = float(loc["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not (_MSK_LAT_MIN <= lat <= _MSK_LAT_MAX
|
||||
and _MSK_LON_MIN <= lon <= _MSK_LON_MAX):
|
||||
continue
|
||||
lot = scraper._map_item(item)
|
||||
if lot is not None:
|
||||
lots.append(lot)
|
||||
return lots
|
||||
|
||||
|
||||
def _domclick_detect_block(html: str) -> tuple[str, str] | None:
|
||||
"""QRATOR/капча DomClick: HTML вместо JSON BFF-ответа.
|
||||
|
||||
Порядок обязателен — сперва пробуем распарсить JSON, и только если не
|
||||
вышло, ищем маркеры (тот же порядок и та же причина, что в докстринге
|
||||
_extract_json в ките, #3267): маркер вроде "система защиты" или "captcha"
|
||||
может встретиться в тексте самого объявления, и подстрочный поиск ДО
|
||||
попытки распарсить JSON забанил живой узел на 6 часов в проде 30.08.
|
||||
Валидный JSON нужной формы блок-страницей быть не может — QRATOR всегда
|
||||
отдаёт HTML, поэтому успешный json.loads сам по себе доказывает «блока
|
||||
нет», и маркеры уже не смотрим.
|
||||
|
||||
HTTP-400-подобное тело ({"statusCode":400,"error":"Bad Request"}, ответ на
|
||||
offset за потолком пагинации) — валидный JSON без result.pagination, а не
|
||||
блок: возвращаем None, конец коридора отловит parse_page по правилу
|
||||
«count is None и 0 карточек». В штатном прогоне это тело недостижимо —
|
||||
DOMCLICK_MAX_PAGES ограничивает offset числом ниже HTTP-400 порога.
|
||||
"""
|
||||
start = html.find("{")
|
||||
end = html.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
try:
|
||||
json.loads(html[start:end + 1])
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
html_lower = html.lower()
|
||||
for marker in DOMCLICK_BLOCK_MARKERS:
|
||||
if marker in html_lower:
|
||||
return "challenge", f"маркер блока DomClick: {marker!r}"
|
||||
return None
|
||||
|
||||
|
||||
async def _domclick_wait_ready(page: Any) -> None:
|
||||
"""BFF отдаёт JSON текстовым узлом в <pre> (как gate-API Яндекса) — ждём его
|
||||
появления коротко; на капче его не будет вовсе, и через 3 с отработает
|
||||
_domclick_detect_block.
|
||||
"""
|
||||
try:
|
||||
await page.wait_for_selector("pre", timeout=3_000)
|
||||
except Exception: # noqa: BLE001 — блок/пустая страница разбирается ниже
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformAdapter:
|
||||
"""Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
|
||||
|
|
@ -566,6 +803,11 @@ class PlatformAdapter:
|
|||
SimpleNamespace(avito_serp_ekb_only=False), # type: ignore[arg-type]
|
||||
target_city_slug="moskva",
|
||||
)
|
||||
if self.name == "domclick":
|
||||
# _map_item — чистая функция над dict (см. providers/domclick/serp.py),
|
||||
# self._config/self._cookies там не читаются, поэтому пустой
|
||||
# namespace достаточен — тот же приём, что и у CianScraper ниже.
|
||||
return DomClickScraper(SimpleNamespace()) # type: ignore[arg-type]
|
||||
# glitchtip_dsn=None — отключает попытку sentry-репорта schema-regression
|
||||
# из _report_schema_regression (нет реального DSN в локальном прогоне).
|
||||
return CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type]
|
||||
|
|
@ -609,6 +851,18 @@ ADAPTERS: dict[str, PlatformAdapter] = {
|
|||
wait_ready=_yandex_wait_ready,
|
||||
default_target=YANDEX_TARGET_COUNT,
|
||||
),
|
||||
"domclick": PlatformAdapter(
|
||||
name="domclick",
|
||||
table="domclick_cards",
|
||||
default_base_url=DEFAULT_DOMCLICK_BASE_URL,
|
||||
page_size=DOMCLICK_PAGE_SIZE,
|
||||
max_pages=DOMCLICK_MAX_PAGES,
|
||||
build_url=_domclick_build_url,
|
||||
extract_total_count=_domclick_extract_total,
|
||||
parse_cards=_domclick_parse_cards,
|
||||
detect_block=_domclick_detect_block,
|
||||
wait_ready=_domclick_wait_ready,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1264,7 +1518,8 @@ async def collect(args: argparse.Namespace) -> int:
|
|||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="collect.py",
|
||||
description="Ручной сбор SERP Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
|
||||
description="Ручной сбор SERP Авито/Циан/Яндекса/DomClick (вторичка, Москва+МО) в"
|
||||
" прод-схему msk_raw.",
|
||||
)
|
||||
p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
|
||||
help="площадка сбора (дефолт avito)")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue