feat(msk-collector): Яндекс как третья площадка сбора по Москве

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 текстом внутри <pre>, поэтому
экранирование разворачивается ДО 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
This commit is contained in:
bot-backend 2026-09-11 18:16:35 +03:00
parent 996b814919
commit c5186883a9
2 changed files with 307 additions and 18 deletions

View file

@ -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` 710 с), `--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-<platform>-<UTC>` — платформа в имени,
чтобы 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-<batch_id>.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=<lo>&priceMax=<hi>&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` ждёт `<pre>`
три секунды и не тратит полный таймаут на селекторы, которых тут не бывает.
Chrome экранирует в тексте `<pre>` символы `&`, `<`, `>`; `_unescape_pre_text`
разворачивает их обратно **до** `json.loads`, иначе описания приезжают с `&amp;`.
### Координаты
`location.point.latitude/longitude` заполнены **у 100 %** карточек (замер на живой
выдаче), плюс `geocoderAddress` с номером дома. Геокодировать Яндекс, в отличие от
Авито (`lat`/`lon` пусты у всех 50 335 карточек), не требуется.
## Стоп на первом признаке блока
Проверки в фиксированном порядке, первое срабатывание = немедленный стоп

View file

@ -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 = (
"&region=-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-документ как текстовый узел внутри <pre>, а сериализатор
# экранирует в тексте ровно три символа. Разэкранируем их обратно ДО json.loads:
# иначе описания приезжают с &amp;/&lt; вместо & и <. Порядок обязателен —
# "&amp;" последним, иначе "&amp;lt;" из описания превратился бы в "<".
def _unescape_pre_text(text: str) -> str:
return text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
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 заворачивает текстовый документ в <pre>, его и ждём, коротко:
к моменту 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-<platform>-<UTC>)")
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")