From c5186883a9f65e6244589a9f2b1c70a6bd848349 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 11 Sep 2026 18:16:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(msk-collector):=20=D0=AF=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=20=D0=BA=D0=B0=D0=BA=20=D1=82=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D1=8C=D1=8F=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=20=D0=9C=D0=BE?= =?UTF-8?q?=D1=81=D0=BA=D0=B2=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rgid Москвы установлен эмпирически из разметки realty.yandex.ru и подтверждён счётчиком офферов gate-API: 587795, вторичка 18 705 против 4 060 у ЕКБ (559132). МО — 587654, Москва+МО — 741964, взят дефолтом по аналогии с region=-1 у Циана. Адаптер повторяет контракт PlatformAdapter, но не тащит DOM-парсер: Яндекс отдаёт SERP через gate-API, из кита берутся только чистые функции разбора gate-payload. `YandexRealtyScraper` не создаётся вовсе — он существует ради BrowserFetcher и пула прокси, а транспорт здесь прежний, вкладка Chrome владельца по CDP. Chrome отдаёт gate-JSON текстом внутри
, поэтому
экранирование разворачивается ДО json.loads, иначе описания приезжают битыми.

Два изменения общего кода, не косметические:
- `--target-count` стал платформо-зависимым (`PlatformAdapter.default_target`):
  1500 у Авито и Циана без изменений, 500 у Яндекса. У Яндекса потолок
  пагинации — 25 страниц по 20 офферов, то есть 500 на набор фильтров, втрое
  ниже соседей; цель коридора выше потолка означала бы, что каждый коридор
  штатно недобирается.
- `Sink.add` отсеивает `source_id` вне signed bigint: offerId Яндекса
  19-значный, выход за диапазон уронил бы `\copy` всего батча, а не одну строку.

Пробный прогон 100 загрузок при задержке 8 с: ни одного признака блока,
350 карточек в `msk_raw.yandex_cards`, координаты и адрес у 100%. В отличие от
Авито, геокод Яндексу не нужен.

Co-Authored-By: Claude Opus 5 
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
---
 tradein-mvp/scripts/local-avito-msk/README.md | 107 ++++++++-
 .../scripts/local-avito-msk/collect.py        | 218 +++++++++++++++++-
 2 files changed, 307 insertions(+), 18 deletions(-)

diff --git a/tradein-mvp/scripts/local-avito-msk/README.md b/tradein-mvp/scripts/local-avito-msk/README.md
index e04bd43d..72d581ce 100644
--- a/tradein-mvp/scripts/local-avito-msk/README.md
+++ b/tradein-mvp/scripts/local-avito-msk/README.md
@@ -1,11 +1,12 @@
-# Локальный сбор SERP Авито/Циан по Москве и МО (эпик #2989, трек 1)
+# Локальный сбор SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1)
 
-`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито
-или Циан (вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`. Площадка —
-ключ `--platform {avito,cian}` (дефолт `avito`). Платформо-зависимые куски (URL
-коридора, счётчик, парс карточек, потолок пагинации, целевая таблица) вынесены в
-`PlatformAdapter` / `ADAPTERS` в `collect.py` — общая часть (бисекция по цене, guard
-на блок, накопитель/заливка в psql) одна на обе платформы.
+`collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито,
+Циан или Яндекс.Недвижимости (вторичка, Москва + МО) и заливает их в прод-схему
+`msk_raw`. Площадка — ключ `--platform {avito,cian,yandex}` (дефолт `avito`).
+Платформо-зависимые куски (URL коридора, счётчик, парс карточек, потолок пагинации,
+целевая таблица) вынесены в `PlatformAdapter` / `ADAPTERS` в `collect.py` — общая
+часть (бисекция по цене, guard на блок, накопитель/заливка в psql) одна на все три
+площадки.
 
 Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
 Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из
@@ -45,8 +46,8 @@
    Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit`
    (путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`).
 
-4. **Миграция под Циан применена на проде** — до первого не-`--dry-run` прогона
-   `--platform cian` выполнить
+4. **Миграция под Циан/Яндекс применена на проде** — до первого не-`--dry-run`
+   прогона `--platform cian` / `--platform yandex` выполнить
    `tradein-mvp/backend/data/sql/299_msk_raw_cian_domclick_yandex_cards.sql`
    (таблицы `msk_raw.cian_cards` в проде пока нет). Путь указан именно так:
    каталога `backend/data/sql` в корне репозитория не существует, файл лежит внутри
@@ -64,13 +65,18 @@ python .\collect.py --dry-run --measure 5
 # 0б) то же для Циан
 python .\collect.py --platform cian --dry-run --measure 5
 
+# 0в) то же для Яндекса
+python .\collect.py --platform yandex --dry-run --measure 5
+
 # 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц)
 python .\collect.py
 python .\collect.py --platform cian
+python .\collect.py --platform yandex
 
 # 2) полный проход — только явно
 python .\collect.py --full --batch-id msk-serp-avito-20260908
 python .\collect.py --platform cian --full --batch-id msk-serp-cian-20260908
+python .\collect.py --platform yandex --full --batch-id msk-serp-yandex-20260911
 
 # 3) продолжить прерванный прогон по сохранённому плану коридоров
 python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
@@ -79,10 +85,11 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
 Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**.
 Дефолт — `--platform avito`.
 
-Ключи: `--platform {avito,cian}` (дефолт avito), `--delay` (пауза между загрузками,
+Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками,
 дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями
 `request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000),
-`--target-count` (целевой размер коридора, дефолт 1500), `--base-url` (дефолт зависит
+`--target-count` (целевой размер коридора; дефолт зависит от площадки — 1500 у
+avito/cian, **500** у yandex, см. `PlatformAdapter.default_target`), `--base-url` (дефолт зависит
 от `--platform`), `--batch-id` (дефолт `msk-serp--` — платформа в имени,
 чтобы avito- и cian-прогоны не затирали друг друга план/CSV), `--out-dir`,
 `--ssh-host/--container/--db-user/--db-name`.
@@ -99,6 +106,7 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
 |---|---|---|---|
 | Авито | 50 (`AVITO_PAGE_SIZE`) | 30 (`AVITO_MAX_PAGES`) | **1500** |
 | Циан | 28 (`_CIAN_OFFERS_PER_PAGE`) | 54 (см. `CianScraper._paginate_leaf_bucket`, "hard cap ~54") | **1512** |
+| Яндекс | 20 (`pager.pageSize`) | 25 (`pager.totalPages`, потолок; `page=26` → HTTP 301) | **500** |
 
 Любой коридор, где `count` больше потолка платформы, целиком не добирается, поэтому
 строится план ценовых коридоров:
@@ -120,6 +128,83 @@ python .\collect.py --full --resume --batch-id msk-serp-avito-20260908
 План лежит в `runs/plan-.json` (включает `platform`, чтобы `--resume` не
 перепутал план Авито с планом Циан) и обновляется после каждой страницы.
 
+## Яндекс: gate-API вместо разметки
+
+Яндекс.Недвижимость — единственная из трёх площадок, у которой **нет разбора DOM**:
+выдача берётся из gate-API и приходит готовым JSON. DOM-парсера у неё нет и в
+`scraper_kit`, поэтому адаптер `yandex` в `collect.py` тащит из кита ровно чистые
+функции разбора gate-payload (`_parse_gate_json`, `_extract_gate_data`,
+`_extract_json_from_content`), а класс `YandexRealtyScraper` не создаёт вовсе: тот
+существует ради camoufox-транспорта и пула прокси, а здесь транспорт — вкладка
+Chrome владельца, как и у остальных.
+
+Запрос:
+
+```
+https://realty.yandex.ru/gate/react-page/get/
+  ?rgid=741964&type=SELL&category=APARTMENT&newFlat=NO
+  &_pageType=search&_providers=react-search-results-data
+  &priceMin=&priceMax=&page=<1..25>
+```
+
+`page` **1-based и проставляется всегда**, в том числе на первой странице:
+`page=0` gate считает ошибкой (у Авито/Циан, наоборот, `p` на первой странице
+опускается).
+
+### rgid
+
+Снят эмпирически 11.09 из SSR-разметки `realty.yandex.ru` (`"geo":{...,"rgid":...}`
+и `page.params`), не из памяти. Счётчики — `pager.totalItems` живого gate-запроса
+по вторичке:
+
+| Скоуп | rgid | Офферов вторички |
+|---|---|---|
+| Москва | 587795 | 18 705 |
+| Московская область | 587654 | 14 242 |
+| **Москва и МО (дефолт)** | **741964** | **31 074** |
+| Екатеринбург (`_EKB_RGID`, контроль) | 559132 | 4 060 |
+
+Дефолт — 741964: единый скоуп «Москва и МО», прямой аналог `region=-1` у Циан и
+`moskva_i_mo` у Авито. Другой скоуп задаётся через `--base-url` с нужным `rgid`.
+
+### Границы коридора включающие с обеих сторон
+
+Замер 11.09 (Москва+МО): `priceMax=5000000` отдаёт выдачу с максимальной ценой
+ровно 5 000 000 (`totalItems=2998`), `priceMin=5000000&priceMax=5000000` — 1279
+(округлая цена популярна у нижней границы рынка), `priceMin=5000000&priceMax=5100000`
+— 1377 = 1279 + 98. То есть **обе границы включающие**. Соседние коридоры бисекции
+`(lo, mid)` и `(mid, upper)` поэтому пересекаются ровно по цене `mid`: дубли гасит
+`ON CONFLICT (source_id,batch_id,kind) DO NOTHING`, потерь между коридорами нет —
+ровно как у Авито и Циан.
+
+### Потолок пагинации — 500, а не 1500
+
+`pager` отдаёт `pageSize=20` и `totalPages=25` даже при `totalItems=31 074`;
+`page=26` отвечает HTTP 301. То есть на один набор фильтров Яндекс отдаёт максимум
+**500 офферов** — втрое меньше Авито и Циан. Поэтому у адаптера свой
+`default_target = 500`: цель коридора не может быть выше потолка площадки, иначе
+бисекция штатно оставляла бы недобранные коридоры. Полный проход Москва+МО — это
+≥ 62 коридора по построению, и в среднем их больше, так что план у Яндекса заметно
+дробнее цианского.
+
+### Блок
+
+Капча Яндекса приходит HTML-страницей на месте JSON, HTTP-статус при этом обычный.
+`_yandex_detect_block` смотрит первые 4 КБ на `smartcaptcha`/`showcaptcha`/`captcha`,
+затем — извлекается ли из документа JSON вообще. Вторая сеть общая с остальными
+площадками: `parse_page` останавливает прогон, если нет ни счётчика, ни карточек.
+Ждать готовности нечего — это текстовый документ, `_yandex_wait_ready` ждёт `
`
+три секунды и не тратит полный таймаут на селекторы, которых тут не бывает.
+
+Chrome экранирует в тексте `
` символы `&`, `<`, `>`; `_unescape_pre_text`
+разворачивает их обратно **до** `json.loads`, иначе описания приезжают с `&`.
+
+### Координаты
+
+`location.point.latitude/longitude` заполнены **у 100 %** карточек (замер на живой
+выдаче), плюс `geocoderAddress` с номером дома. Геокодировать Яндекс, в отличие от
+Авито (`lat`/`lon` пусты у всех 50 335 карточек), не требуется.
+
 ## Стоп на первом признаке блока
 
 Проверки в фиксированном порядке, первое срабатывание = немедленный стоп
diff --git a/tradein-mvp/scripts/local-avito-msk/collect.py b/tradein-mvp/scripts/local-avito-msk/collect.py
index 4143cb6c..1a349360 100644
--- a/tradein-mvp/scripts/local-avito-msk/collect.py
+++ b/tradein-mvp/scripts/local-avito-msk/collect.py
@@ -1,14 +1,14 @@
 #!/usr/bin/env python3
-"""Локальный ручной сборщик SERP Авито/Циан по Москве и МО (эпик #2989, трек 1).
+"""Локальный ручной сборщик SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1).
 
 Запускается ВРУЧНУЮ с машины владельца. Прод-скрейпер, его расписания и
 прокси-пул не задействованы вообще: браузер — уже открытый Chrome владельца
 (подключение по CDP), парсер — импорт из scraper-kit, заливка — поток в psql
 через ssh. Скрипт ничего не устанавливает и своего профиля не поднимает.
 
-Платформа выбирается ключом --platform {avito,cian} (дефолт avito) — см. класс
-PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой билдер
-URL коридора и своя целевая таблица в msk_raw.
+Платформа выбирается ключом --platform {avito,cian,yandex} (дефолт avito) — см.
+класс PlatformAdapter ниже. У каждой платформы свой потолок пагинации, свой
+билдер URL коридора и своя целевая таблица в msk_raw.
 
 Дефолтный режим — --measure 100 (замер): полный проход только по явному --full.
 """
@@ -48,6 +48,16 @@ from scraper_kit.providers.cian.serp import (  # noqa: E402
     CianScraper,
     _CIAN_OFFERS_PER_PAGE,
 )
+# Яндекс ходит в gate-API и отдаёт JSON, а не SERP-разметку: DOM-парсера у него нет
+# и тащить его сюда нечего. Из кита берём ровно чистые функции разбора gate-payload —
+# класс YandexRealtyScraper не нужен (он существует ради BrowserFetcher/camoufox и
+# пула прокси, а здесь транспорт — вкладка Chrome владельца).
+from scraper_kit.providers.yandex.serp import (  # noqa: E402
+    _extract_gate_data,
+    _extract_json_from_content,
+    _is_gate_error,
+    _parse_gate_json,
+)
 
 # Вкладка, открытая у владельца: вторичка, Москва + МО.
 DEFAULT_AVITO_BASE_URL = (
@@ -75,6 +85,33 @@ DEFAULT_CIAN_BASE_URL = (
     "®ion=-1&object_type%5B0%5D=1&sort=creation_date_desc"
 )
 
+# Яндекс.Недвижимость: gate-API (JSON), а не HTML-выдача.
+#
+# rgid установлен ЭМПИРИЧЕСКИ 11.09, а не взят из памяти: в SSR-разметке
+# realty.yandex.ru/moskva/kupit/kvartira/vtorichniy-rynok/ лежит
+#   "geo":{"id":213,"type":"CITY","rgid":587795,...,"name":"Москва",
+#          "parents":[{"id":1,"name":"Москва и МО","rgid":"741964",...}]}
+# и там же page.params = {"rgid":587795,"type":"SELL","category":"APARTMENT",
+# "newFlat":"NO"} — ровно параметры gate-запроса. Аналогично со страницы МО снят
+# rgid 587654 ("Московская область", SUBJECT_FEDERATION).
+#
+# Счётчики вторички gate-API (pager.totalItems, живой запрос 11.09):
+#   rgid=587795 (Москва)        18 705
+#   rgid=587654 (МО)            14 242
+#   rgid=741964 (Москва и МО)   31 074
+#   rgid=559132 (ЕКБ, _EKB_RGID)  4 060   ← контроль: московский скоуп в 7.6 раза больше
+# Дефолт — 741964: единый скоуп «Москва и МО», как region=-1 у Циан и
+# moskva_i_mo у Авито.
+_YANDEX_MSK_RGID = 587795
+_YANDEX_MO_RGID = 587654
+_YANDEX_MSK_MO_RGID = 741964
+
+DEFAULT_YANDEX_BASE_URL = (
+    "https://realty.yandex.ru/gate/react-page/get/"
+    f"?rgid={_YANDEX_MSK_MO_RGID}&type=SELL&category=APARTMENT&newFlat=NO"
+    "&_pageType=search&_providers=react-search-results-data"
+)
+
 # Замерено живым проходом (не из документации): выдача Москва+МО отдаёт 50 карточек
 # на страницу. Пока здесь стояло 60, planned_pages считал count/60 и не запрашивал
 # последние ~17% каждого коридора — 34 753 по счётчику против 28 352 собранных.
@@ -89,6 +126,18 @@ AVITO_MAX_PAGES = 30           # потолок пагинации Авито 
 CIAN_PAGE_SIZE = _CIAN_OFFERS_PER_PAGE
 CIAN_MAX_PAGES = 54
 
+# Замерено живым gate-запросом 11.09, не взято из кита: pager отдаёт
+# pageSize=20 и totalPages=25 ДАЖЕ когда totalItems=31 074 (то есть 1554
+# страницы по построению). page=26 отвечает HTTP 301 — редирект, не выдача.
+# Значит жёсткий потолок Яндекса = 25*20 = 500 офферов на один набор фильтров,
+# втрое ниже авитовского и цианского. Константа _GATE_MAX_PAGES_CAP=50 в
+# scraper_kit — исторический запас, живьём недостижима.
+YANDEX_PAGE_SIZE = 20
+YANDEX_MAX_PAGES = 25
+# Целевой размер коридора не может превышать hard_cap площадки, иначе бисекция
+# штатно «дорезает» до 1500 и каждый коридор уезжает в truncated.
+YANDEX_TARGET_COUNT = YANDEX_PAGE_SIZE * YANDEX_MAX_PAGES   # 500
+
 PRICE_FLOOR = 500_000           # нижняя граница осмысленного коридора, ₽
 PRICE_PROBE_START = 8_000_000   # старт удвоения при поиске верхней границы
 PRICE_CEIL = 2_000_000_000
@@ -334,6 +383,129 @@ def _cian_parse_cards(scraper: CianScraper, html: str) -> list[Any]:
     return lots
 
 
+# --- Яндекс: gate-API (JSON), общего с DOM-платформами только конвейер ---------
+
+# Chrome отдаёт JSON-документ как текстовый узел внутри 
, а сериализатор
+# экранирует в тексте ровно три символа. Разэкранируем их обратно ДО json.loads:
+# иначе описания приезжают с &/< вместо & и <. Порядок обязателен —
+# "&" последним, иначе "&lt;" из описания превратился бы в "<".
+def _unescape_pre_text(text: str) -> str:
+    return text.replace("<", "<").replace(">", ">").replace("&", "&")
+
+
+def _yandex_payload(scraper: Any, html: str) -> dict[str, Any] | None:
+    """gate-payload из содержимого вкладки, с мемоизацией на один HTML.
+
+    parse_page дёргает сначала extract_total_count, потом parse_cards — оба по
+    одной и той же строке. JSON тут на сотни КБ, поэтому разбираем один раз и
+    кладём на scraper вместе с самой строкой. Сравнение — `is`, и ссылка на
+    строку хранится: по id() было бы неверно, id освобождённой строки может
+    достаться следующей.
+    """
+    if getattr(scraper, "_payload_html", None) is html:
+        return scraper._payload
+    json_text = _extract_json_from_content(html)
+    payload: dict[str, Any] | None = None
+    if json_text:
+        try:
+            payload = json.loads(_unescape_pre_text(json_text))
+        except (json.JSONDecodeError, ValueError):
+            payload = None
+    if payload is not None and _is_gate_error(payload):
+        payload = None
+    scraper._payload_html = html
+    scraper._payload = payload
+    return payload
+
+
+def _yandex_build_url(base_url: str, page: int, lo: int | None, hi: int | None) -> str:
+    """URL коридора Яндекса: priceMin/priceMax + page (1-based).
+
+    page=0 gate-API считает ошибкой, поэтому page проставляется ВСЕГДА, в том
+    числе на первой странице, — в отличие от Авито/Циан, где `p` на первой
+    странице опускается.
+    """
+    parts = urlsplit(base_url)
+    q = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
+         if k not in {"page", "priceMin", "priceMax"}]
+    if lo is not None:
+        q.append(("priceMin", str(int(lo))))
+    if hi is not None:
+        q.append(("priceMax", str(int(hi))))
+    q.append(("page", str(max(1, page))))
+    return urlunsplit(
+        (parts.scheme, parts.netloc, parts.path, urlencode(q), parts.fragment)
+    )
+
+
+def _yandex_extract_total(scraper: Any, html: str) -> int | None:
+    payload = _yandex_payload(scraper, html)
+    if payload is None:
+        return None
+    extracted = _extract_gate_data(payload)
+    if extracted is None:
+        return None
+    _entities, pager = extracted
+    total = pager.get("totalItems")
+    return int(total) if total is not None else None
+
+
+def _yandex_parse_cards(scraper: Any, html: str) -> list[Any]:
+    """Разбор gate-payload в ScrapedLot теми же функциями, что и прод-скрейпер.
+
+    page_param берём из самого ответа (response.pageParams.params.page), а не из
+    аргумента: контракт адаптера номера страницы не передаёт, а в raw_payload
+    должна лечь та страница, которую реально отдал gate.
+    """
+    payload = _yandex_payload(scraper, html)
+    if payload is None:
+        scraper.last_raw_count = 0
+        return []
+    extracted = _extract_gate_data(payload)
+    if extracted is None:
+        scraper.last_raw_count = 0
+        return []
+    entities, _pager = extracted
+    # Сырое число — до отбраковки лотов без offerId/цены в _entity_to_lot.
+    scraper.last_raw_count = len(entities)
+    try:
+        page_param = int(payload["response"]["pageParams"]["params"]["page"])
+    except (KeyError, TypeError, ValueError):
+        page_param = 1
+    return _parse_gate_json(payload, page_param=page_param, new_flat="NO")
+
+
+def _yandex_detect_block(html: str) -> tuple[str, str] | None:
+    """Блок Яндекса: SmartCaptcha приходит HTML-страницей на месте JSON.
+
+    Порядок важен: сперва ищем маркеры капчи (иначе «нет JSON» рапортовалось бы
+    как schema-drift), потом проверяем, что документ вообще похож на gate-ответ.
+    Пустой gate-payload без response — тоже стоп: это заглушка, а не выдача.
+    """
+    head = html[:4096].lower()
+    if "smartcaptcha" in head or "showcaptcha" in head or "captcha" in head:
+        return "challenge", "SmartCaptcha Яндекса вместо gate-ответа"
+    json_text = _extract_json_from_content(html)
+    if not json_text:
+        return "challenge", "во вкладке нет JSON (gate отдал не тот документ)"
+    return None
+
+
+async def _yandex_wait_ready(page: Any) -> None:
+    """gate-API — документ JSON, клиентской дорисовки нет.
+
+    Ждать `[data-marker=item]` (Авито) или window._cianConfig (Циан) здесь
+    бессмысленно — их не будет никогда, каждая загрузка стоила бы полного
+    таймаута. Chrome заворачивает текстовый документ в 
, его и ждём, коротко:
+    к моменту domcontentloaded он, как правило, уже на месте, а на капче его нет
+    вовсе — и тогда через 3 с отработает _yandex_detect_block.
+    """
+    try:
+        await page.wait_for_selector("pre", timeout=3_000)
+    except Exception:  # noqa: BLE001 — капча/заглушка разбирается detect_block ниже
+        pass
+
+
 @dataclass(frozen=True)
 class PlatformAdapter:
     """Платформо-зависимые куски сбора. Всё общее (бисекция, guard по HTTP-статусу,
@@ -349,12 +521,21 @@ class PlatformAdapter:
     parse_cards: Callable[[Any, str], list[Any]]
     detect_block: Callable[[str], tuple[str, str] | None]
     wait_ready: Callable[[Any], Any]   # корутина: дождаться готовности страницы
+    # Дефолт --target-count. Он обязан быть <= hard_cap площадки, иначе бисекция
+    # штатно оставляет коридоры, которые заведомо не вычитываются до конца.
+    default_target: int = 1500
 
     @property
     def hard_cap(self) -> int:
         return self.page_size * self.max_pages
 
     def make_scraper(self) -> Any:
+        if self.name == "yandex":
+            # У Яндекса разбор — чистые функции над gate-JSON; YandexRealtyScraper
+            # нужен только ради camoufox-транспорта и пула прокси, которых здесь
+            # нет (транспорт — вкладка Chrome владельца). Носитель состояния для
+            # мемоизации payload и last_raw_count — пустой namespace.
+            return SimpleNamespace()
         if self.name == "avito":
             # avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё,
             # где в URL нет /ekaterinburg/ — то есть все подмосковные слаги
@@ -393,6 +574,19 @@ ADAPTERS: dict[str, PlatformAdapter] = {
         detect_block=_cian_detect_block,
         wait_ready=_cian_wait_ready,
     ),
+    "yandex": PlatformAdapter(
+        name="yandex",
+        table="yandex_cards",
+        default_base_url=DEFAULT_YANDEX_BASE_URL,
+        page_size=YANDEX_PAGE_SIZE,
+        max_pages=YANDEX_MAX_PAGES,
+        build_url=_yandex_build_url,
+        extract_total_count=_yandex_extract_total,
+        parse_cards=_yandex_parse_cards,
+        detect_block=_yandex_detect_block,
+        wait_ready=_yandex_wait_ready,
+        default_target=YANDEX_TARGET_COUNT,
+    ),
 }
 
 
@@ -728,6 +922,12 @@ class Sink:
         except (TypeError, ValueError):
             self.skipped_non_numeric += 1
             return
+        # offerId Яндекса — 19-значный (напр. 3937530341842304552), это уже
+        # впритык к signed bigint. Число сверх диапазона уронило бы \copy всего
+        # батча целиком, а не одну строку, поэтому отсекаем здесь же.
+        if not (-(2**63) <= source_id < 2**63):
+            self.skipped_non_numeric += 1
+            return
         payload = lot.model_dump(mode="json")
         self.buffer.append({
             "source_id": source_id,
@@ -1015,7 +1215,7 @@ 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 Авито/Циан/Яндекса (вторичка, Москва+МО) в прод-схему msk_raw.",
     )
     p.add_argument("--platform", choices=tuple(ADAPTERS), default="avito",
                    help="площадка сбора (дефолт avito)")
@@ -1033,8 +1233,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
                    help="пауза между загрузками, с (±20%% джиттер, дефолт 8.0)")
     p.add_argument("--batch-size", type=int, default=1000,
                    help="карточек в одной заливке (дефолт 1000)")
-    p.add_argument("--target-count", type=int, default=1500,
-                   help="целевой размер коридора; больше — делим (дефолт 1500)")
+    p.add_argument("--target-count", type=int, default=None,
+                   help="целевой размер коридора; больше — делим "
+                        "(дефолт зависит от --platform: 1500 у avito/cian, "
+                        "500 у yandex — там потолок пагинации 25*20)")
     p.add_argument("--batch-id", default=None,
                    help="batch_id в msk_raw.batches (дефолт msk-serp--)")
     p.add_argument("--out-dir", default=str(Path(__file__).resolve().parent / "runs"),
@@ -1047,6 +1249,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
     args = p.parse_args(argv)
     if args.base_url is None:
         args.base_url = ADAPTERS[args.platform].default_base_url
+    if args.target_count is None:
+        args.target_count = ADAPTERS[args.platform].default_target
     if args.batch_id is None:
         args.batch_id = (
             f"msk-serp-{args.platform}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")