|
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
|
||
|---|---|---|
| .. | ||
| .gitignore | ||
| collect.py | ||
| README.md | ||
Локальный сбор 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.
Предусловия
-
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 владельца. -
ssh-доступ на прод (
ssh selectelбез пароля) — для заливки в БД. Прямого подключения к прод-Postgres с локалки нет: туннель:35432ведёт в мёртвую копию на Beget. -
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__). -
Миграция под Циан/Яндекс применена на проде — до первого не-
--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 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).
Как проверить залитое
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.