# Локальный сбор SERP Авито по Москве и МО (эпик #2989, трек 1) `collect.py` — ручной скрипт **с машины владельца**. Собирает карточки выдачи Авито (вторичка, Москва + МО) и заливает их в прод-схему `msk_raw`. Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**. Браузер — уже открытый 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__`). ## Запуск (PowerShell) ```powershell cd D:\prjct\gendesign\tradein-mvp\scripts\local-avito-msk # 0) сухой прогон: ничего не шлём на прод, карточки пишем в runs\cards-.csv python .\collect.py --dry-run --measure 5 # 1) обязательный первый прогон — замер (дефолт, 100 загрузок страниц) python .\collect.py # 2) полный проход — только явно python .\collect.py --full --batch-id msk-serp-20260908 # 3) продолжить прерванный прогон по сохранённому плану коридоров python .\collect.py --full --resume --batch-id msk-serp-20260908 ``` Без аргументов скрипт работает в режиме `--measure 100` и полный проход **не начинает**. Ключи: `--delay` (пауза между загрузками, дефолт 8.0 с ±20 % джиттера — сознательно совпадает с прод-расписаниями `request_delay_sec` 7–10 с), `--batch-size` (карточек в одной заливке, дефолт 1000), `--target-count` (целевой размер коридора, дефолт 1500), `--base-url`, `--batch-id`, `--out-dir`, `--ssh-host/--container/--db-user/--db-name`. ## Как режется выдача Потолок пагинации Авито — 30 страниц по 60 = **1800 объявлений на запрос**. Любой запрос с `count > 1800` целиком не добирается, поэтому строится план ценовых коридоров: * читаем счётчик «N объявлений» со страницы 1 (`page-title/count`); * `count > --target-count` → делим коридор пополам **по геометрической середине** (`sqrt(lo*hi)`): цены логнормальны, арифметическая середина диапазона 1 млн … 100 млн даёт вырожденно-пустую верхнюю половину; * верхняя граница открытого коридора подбирается удвоением от 8 млн ₽; * предохранители: глубина рекурсии ≤ 12 и минимальная ширина коридора (отношение границ ≤ 1.05). Если коридор уже узкий, а `count` всё ещё > 1800 — он помечается `truncated: true` в плане, а в лог и в `msk_raw.batches.notes` пишется, сколько объявлений заведомо не добрано; * гео-параметры `radius`/`geoCoords` не используются: сервером они не применяются (#3043). План лежит в `runs/plan-.json` и обновляется после каждой страницы — отсюда работает `--resume`. ## Стоп на первом признаке блока Проверки в фиксированном порядке, первое срабатывание = немедленный стоп (никаких ретраев и никакого «продолжим со следующего коридора»): | # | Признак | Причина в `notes` | |---|---|---| | 1 | HTTP 403 / 439 | `platform` | | 2 | HTTP 429 | `ratelimit` | | 3 | `_is_firewall_page(html)` — «доступ ограничен», «проблема с ip», `firewall-container` | `firewall` | | 4 | `startpow` / «доступ ограничен: проверка безопасности» в первых 4 КБ | `challenge` | | 5 | 0 карточек при ненулевом счётчике (DOM-drift или тихий блок) | `empty_page` | При стопе: недоотправленный батч дозаливается, у батча проставляются `finished_at` и `notes`, скрипт выходит с кодом **2**. **Что делать при стопе.** Не перезапускать сразу и не крутить ретраи. `platform` / `firewall` / `challenge` — техаккаунт или IP помечены: пауза на несколько часов, проверить вручную в браузере, что выдача открывается и аккаунт жив, при повторе увеличить `--delay`. `ratelimit` — темп слишком высокий: `--delay 15` и выше. `empty_page` — сначала посмотреть сохранённую страницу в браузере: если выдача рисуется, значит уехал DOM и чинить надо парсер в `scraper-kit`, а не скрипт. После разбора — `--resume` с тем же `--batch-id`, уже собранное не потеряется. ## Куда пишем Схема `msk_raw` на проде (создана заранее): * `msk_raw.batches(batch_id PK, kind, query, started_at, finished_at, rows_sent, rows_new, notes, uploaded_at)` * `msk_raw.avito_cards(id, source_id, observed_at, batch_id → batches, kind, url, price, payload, UNIQUE(source_id,batch_id,kind))` * `msk_raw.avito_latest` — вью `DISTINCT ON (source_id) … ORDER BY source_id, observed_at DESC` Форма заливки: поток в `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.avito_cards INCLUDING DEFAULTS)`, `\copy _stg (...) FROM STDIN WITH (FORMAT csv)`, затем `INSERT … SELECT` в `avito_cards` с `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/`, включая все подмосковные слаги — из московской выдачи не осталось бы ничего.