gendesign/tradein-mvp/scripts/local-avito-msk
lekss361 628f59dcc0
All checks were successful
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 1m1s
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s
ДомКлик — четвёртая площадка ручного сбора Москвы (#3496)
2026-09-12 12:05:18 +00:00
..
.gitignore fix(msk-collector): узкий ретрай page.content() на гонке перенавигации 2026-09-08 23:44:09 +03:00
collect.py ДомКлик — четвёртая площадка ручного сбора Москвы (#3496) 2026-09-12 12:05:18 +00:00
README.md feat(msk-collector): Яндекс как третья площадка сбора по Москве 2026-09-11 18:16:35 +03:00

Локальный сбор 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:

    & "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 в текущем интерпретаторе:

    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)

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

Как проверить залитое

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.