gendesign/tradein-mvp/scripts/local-avito-msk
bot-backend bde38cdce7
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(msk-collector): --resume берёт base_url из сохранённого плана
Коридоры считаются под конкретный URL выдачи. При --resume код брал свежий
args.base_url, поэтому запуск без повтора --base-url молча качал другую
выдачу под тем же batch_id. Теперь URL из плана, расхождение с аргументом —
явная ошибка вместо тихого выбора одного из двух.
2026-09-08 22:54:44 +03:00
..
collect.py fix(msk-collector): --resume берёт base_url из сохранённого плана 2026-09-08 22:54:44 +03:00
README.md feat(#2989): локальный ручной сборщик SERP Авито по Москве и МО 2026-09-08 22:48:37 +03:00

Локальный сбор SERP Авито по Москве и МО (эпик #2989, трек 1)

collect.py — ручной скрипт с машины владельца. Собирает карточки выдачи Авито (вторичка, Москва + МО) и заливает их в прод-схему msk_raw.

Прод-скрейпер, его расписания, прокси-пул и сайдкар не задействованы вообще. Браузер — уже открытый 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__).

Запуск (PowerShell)

cd D:\prjct\gendesign\tradein-mvp\scripts\local-avito-msk

# 0) сухой прогон: ничего не шлём на прод, карточки пишем в runs\cards-<batch>.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 710 с), --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-<batch_id>.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 <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.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).

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

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/, включая все подмосковные слаги — из московской выдачи не осталось бы ничего.