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")