gendesign/tradein-mvp/scripts/local-avito-msk/README.md
bot-backend c5186883a9 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
2026-09-11 18:16:35 +03:00

313 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Локальный сбор 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` 710 с), `--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`, иначе описания приезжают с `&amp;`.
### Координаты
`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&region=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`.