From 628f59dcc05189c5960a86a2cb840d943eb818aa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 12 Sep 2026 12:05:18 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B5=D1=82=D0=B2=D1=91=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D1=8B=20(#3496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/local-avito-msk/collect.py | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py index a0132cb1..30cd801d 100644 --- a/tradein-mvp/scripts/local-avito-msk/collect.py +++ b/tradein-mvp/scripts/local-avito-msk/collect.py @@ -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-экранирование текстового узла
, в который 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 текстовым узлом в 
 (как 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)")