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
313 lines
23 KiB
Markdown
313 lines
23 KiB
Markdown
# Локальный сбор 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-<batch>.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-<platform>-<UTC>` — платформа в имени,
|
||
чтобы 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-<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`, иначе описания приезжают с `&`.
|
||
|
||
### Координаты
|
||
|
||
`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 <host> "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.<table>
|
||
INCLUDING DEFAULTS)`, `\copy _stg (...) FROM STDIN WITH (FORMAT csv)`, затем
|
||
`INSERT … SELECT` в `msk_raw.<table>` (`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`.
|