# Локальный сбор SERP Авито/Циан/Яндекса по Москве и МО (эпик #2989, трек 1) `collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито, Циан или Яндекс.Недвижимости (вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`. Площадка — ключ `--platform {avito,cian,yandex}` (дефолт `avito`). Платформо-зависимые куски (URL коридора, счётчик, парс карточек, потолок пагинации, целевая таблица) вынесены в `PlatformAdapter` / `ADAPTERS` в `collect.py` — общая часть (бисекция по цене, guard на блок, накопитель/заливка в psql) одна на все три площадки. Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**. Браузер — уже открытый Chrome владельца (подключение по CDP), парсер — импорт из `packages/scraper-kit`, заливка — поток в `psql` через `ssh`. ## Предусловия 1. **Chrome владельца** запущен с залогиненным техническим аккаунтом Авито и remote debugging: ```powershell & "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 ``` Проверка: `curl http://localhost:9222/json/version` отдаёт JSON с `Browser: Chrome/...`. Другой адрес — переменная `AVITO_CDP`. Скрипт **не поднимает свой профиль** (`launch_persistent_context` не используется): он подключается к существующему браузеру, берёт `browser.contexts[0]`, открывает **свою** вкладку и в конце закрывает **только её**. Чужие вкладки, контекст и сам браузер не трогаются — это рабочий Chrome владельца. 2. **ssh-доступ на прод** (`ssh selectel` без пароля) — для заливки в БД. Прямого подключения к прод-Postgres с локалки нет: туннель `:35432` ведёт в мёртвую копию на Beget. 3. **Playwright в текущем интерпретаторе**: ```powershell pip install playwright python -m playwright install chromium ``` В uv-зависимости проекта playwright **не добавлять**: прод-сайдкар пинит playwright 1.60 под camoufox, и подъём версии сломает его. Больше ничего ставить не нужно: только stdlib + playwright + импорт `scraper_kit` (путь `packages/scraper-kit/src` скрипт добавляет в `sys.path` сам, от `__file__`). 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` в корне репозитория не существует, файл лежит внутри `tradein-mvp/`. Сборщик проверяет наличие целевой таблицы на старте и падает сразу, не начиная обход выдачи. ## Запуск (PowerShell) ```powershell cd D:\prjct\gendesign\tradein-mvp\scripts\local-avito-msk # 0) сухой прогон Авито: ничего не шлём на прод, карточки пишем в runs\cards-.csv 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 ``` Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**. Дефолт — `--platform avito`. Ключи: `--platform {avito,cian,yandex}` (дефолт avito), `--delay` (пауза между загрузками, дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями `request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000), `--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`. `AVITO_CDP` (адрес CDP, дефолт `http://localhost:9222`) общий для обеих платформ — это адрес браузера владельца, а не площадки; имя переменной оставлено историческим. ## Как режется выдача Обе площадки режутся одной и той же адаптивной бисекцией по цене, но с **разным потолком пагинации на запрос**: | Платформа | Карточек/страница | Потолок страниц | Потолок объявлений/запрос | |---|---|---|---| | Авито | 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` больше потолка платформы, целиком не добирается, поэтому строится план ценовых коридоров: * читаем счётчик найденных объявлений со страницы 1 (Авито — `page-title/count`; Циан — `results.totalOffers` из Redux-state SSR-страницы, `_extract_total_offers`); * `count > --target-count` → делим коридор пополам **по геометрической середине** (`sqrt(lo*hi)`): цены логнормальны, арифметическая середина диапазона 1 млн … 100 млн даёт вырожденно-пустую верхнюю половину; * верхняя граница открытого коридора подбирается удвоением от 8 млн ₽; * предохранители: глубина рекурсии ≤ 12 и минимальная ширина коридора (отношение границ ≤ 1.05). Если коридор уже узкий, а `count` всё ещё больше потолка платформы — он помечается `truncated: true` в плане, а в лог и в `msk_raw.batches.notes` пишется, сколько объявлений заведомо не добрано; * гео-параметры типа `radius`/`geoCoords` не используются: для Авито сервером они не применяются (#3043); для Циан фильтрация по гео вообще не поддерживается на уровне SERP (сервер отдаёт весь регион, `region=` — единственный гео-скоуп). План лежит в `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 карточек), не требуется.

## Стоп на первом признаке блока

Проверки в фиксированном порядке, первое срабатывание = немедленный стоп
(никаких ретраев и никакого «продолжим со следующего коридора»):

| # | Признак | Причина в `notes` | Платформа |
|---|---|---|---|
| 1 | HTTP 403 / 439 | `platform` | обе |
| 2 | HTTP 429 | `ratelimit` | обе |
| 3 | `_is_firewall_page(html)` — «доступ ограничен», «проблема с ip», `firewall-container` | `firewall` | avito |
| 4 | `startpow` / «доступ ограничен: проверка безопасности» в первых 4 КБ | `challenge` | avito |
| 5 | 0 карточек при ненулевом счётчике (DOM-drift или тихий блок) | `empty_page` | обе |
| 6 | три подряд таймаута навигации (`Blocked("nav_timeout")` в `Loader._goto`) | `nav_timeout` | обе |

Для Циан текстовых маркеров блока (аналог п.3/4) пока нет — не проверялись живым
прогоном (задание такого прогона и не предполагало). Блок на Циан обычно всплывает как
проваленное извлечение Redux-state (`extract_state` вернул `None` — капча/смена
вёрстки), что уже покрыто общим `empty_page` (0 карточек при ненулевом счётчике) и
стопом `счётчик не прочитался` при `count is None` во время построения плана. Если на
практике всплывёт свой текстовый маркер блока Циан — его место в `_cian_detect_block`
в `collect.py`.

При стопе: недоотправленный батч дозаливается, у батча проставляются `finished_at` и
`notes`, скрипт выходит с кодом **2**.

**Что делать при стопе.** Не перезапускать сразу и не крутить ретраи. `platform` /
`firewall` / `challenge` — техаккаунт или IP помечены: пауза на несколько часов,
проверить вручную в браузере, что выдача открывается и аккаунт жив, при повторе
увеличить `--delay`. `ratelimit` — темп слишком высокий: `--delay 15` и выше.
`empty_page` — сначала посмотреть сохранённую страницу в браузере: если выдача
рисуется, значит уехал DOM и чинить надо парсер в `scraper-kit`, а не скрипт.
`nav_timeout` — **не признак бана**: страница трижды подряд не догрузилась за таймаут.
Проверить сеть/VPN и саму вкладку в браузере владельца (жива ли, не висит ли диалог),
при необходимости поднять `--delay`. Циан-страницы тяжелее авитовских (SSR c Redux-state),
поэтому там `nav_timeout` вероятнее — это ожидаемо, паузы на часы он не требует.
После разбора — `--resume` с тем же `--batch-id`, уже собранное не потеряется.

## Куда пишем

Схема `msk_raw` на проде:

* `msk_raw.batches(batch_id PK, kind, query, started_at, finished_at, rows_sent, rows_new, notes, uploaded_at)`
  — общая для всех платформ (батч различается только по `batch_id`).
* `msk_raw.avito_cards(id, source_id, observed_at, batch_id → batches, kind, url, price, payload, UNIQUE(source_id,batch_id,kind))`
* `msk_raw.cian_cards` — **та же схема**, что и `avito_cards` (тот же набор колонок и
  тот же `UNIQUE(source_id,batch_id,kind)`); таблица создаётся отдельной SQL-миграцией
  (database-expert) до первого прогона `--platform cian` — этот скрипт DDL не запускает.
* `msk_raw.avito_latest` / `msk_raw.cian_latest` — вью `DISTINCT ON (source_id) …
  ORDER BY source_id, observed_at DESC` (аналогично `cian_latest`, тоже отдельная миграция).

Форма заливки: поток в
`ssh  "docker exec -i tradein-postgres psql -U tradein -d tradein -v ON_ERROR_STOP=1 -f -"`.
Внутри одной транзакции: `INSERT` батча (`ON CONFLICT DO NOTHING` — строка обязана
существовать до карточек из-за FK), `CREATE TEMP TABLE _stg (LIKE msk_raw.
INCLUDING DEFAULTS)`, `\copy _stg (...) FROM STDIN WITH (FORMAT csv)`, затем
`INSERT … SELECT` в `msk_raw.
` (`avito_cards` или `cian_cards`, по `--platform`) с `ON CONFLICT (source_id,batch_id,kind) DO NOTHING` и `UPDATE batches SET rows_sent/rows_new` (`rows_new` = разница `count(*)` по batch_id до и после вставки). Одиночный `psql -c` через ssh ломается на квотинге скобок и кавычек — поэтому только поток. `payload` = `lot.model_dump(mode="json")`. `source_id` в БД `bigint`, а у `ScrapedLot` — строка: приводится к `int`, нечисловые пропускаются со счётчиком (он попадает в `notes` как `skipped_non_numeric=N`). ## Как проверить залитое ```powershell ssh selectel "docker exec -i tradein-postgres psql -U tradein -d tradein -c \"SELECT count(*) FROM msk_raw.avito_latest;\"" ssh selectel "docker exec -i tradein-postgres psql -U tradein -d tradein -c \"SELECT batch_id, rows_sent, rows_new, started_at, finished_at, notes FROM msk_raw.batches ORDER BY uploaded_at DESC LIMIT 5;\"" ``` ## Про фильтр городов и площадок Авито: парсер конструируется как `AvitoScraper(SimpleNamespace(avito_serp_ekb_only=False), target_city_slug="moskva")`. `avito_serp_ekb_only=False` **обязателен**: с `True` `_parse_html` выбрасывает всё, у чего в URL нет `/ekaterinburg/`, включая все подмосковные слаги — из московской выдачи не осталось бы ничего. URL берётся с готовым слагом `vtorichka` — фильтр «только вторичка» задан самим URL. Циан: `CianScraper(SimpleNamespace(glitchtip_dsn=None))`. Домен URL — `www.cian.ru`, базовый скоуп — `region=-1` (Москва и МО **одним** параметром) плюс `object_type[0]=1` (вторичка). Два параметра `region=` не объединяются — сервер берёт последний, поэтому прежняя пара `region=1®ion=4593` отдавала только область. Живой замер 10.09 по `region=-1` + `object_type[0]=1`: **62 548 объявлений**. У `_build_url` из `CianScraper` домен захардкожен под ЕКБ (`self.base_url = "https://ekb.cian.ru"`), поэтому URL коридора строит свой билдер `_cian_build_url` в `collect.py`, а не метод скрапера — используются только чистые парс-функции скрапера (`_extract_total_offers`, `_parse_serp_html`), которым домен запроса не важен. Вторичка у Циан задана в URL (`object_type[0]=1`), и **постфильтра новостроек в адаптере нет** — это сознательное отличие от кита. `CianScraper` внутри `_paginate_leaf_bucket`/`fetch_around` (эти методы скрипт не вызывает) выбрасывает всё, у чего есть `offer.newbuilding.id`, считая SERP-параметр `object_type=1` ненадёжным (`serp.py:401-402`). Для ЕКБ, где выдача бралась без этого параметра, так и было правильно. Здесь — нет. Замер живой страницы 10.09: из 28 карточек 16 имеют блок `newbuilding`, и у всех шестнадцати `isFromBuilder=false`, `isFromLeadFactory=false`, `flatType=rooms`, а ЖК — «Пригород Лесное», «РУСИЧ Новые Котельники» и подобные. Это обычная вторичка в новых домах, а не лоты застройщика: `newbuilding.id` означает «дом в ЖК». С китовым фильтром прогон терял бы **57 % корпуса**, причём безвозвратно. `msk_raw` — сырьё: `payload` несёт `listing_segment` целиком, поэтому сегмент отделяется позже, на импорте в `listings`. Адаптер только считает карточки со ссылкой на ЖК — `scraper.last_nb_ref` попадает в `notes` как `nb_ref=N`.