gendesign/tradein-mvp/scripts/local-avito-msk/README.md
bot-backend 7b33ed9a45 feat(#2989): локальный ручной сборщик SERP Авито по Москве и МО
Трек 1 эпика: нужен разовый корпус вторички Москвы+МО в msk_raw, а прод-скрейпер
для этого не подходит — его расписания, прокси-пул и сайдкар держат ЕКБ и трогать
их ради ручного замера нельзя.

Поэтому скрипт: браузер — уже открытый Chrome владельца с залогиненным техаккаунтом
(connect_over_cdp, своя вкладка, чужие вкладки/контекст/браузер не трогаем и не
закрываем; своего профиля не поднимаем); парсер — импорт _parse_html /
_extract_total_count / _is_firewall_page из scraper-kit, а не копия (копия разъедется
с прод-парсером на первом же DOM-drift); заливка — поток в psql через ssh, потому что
прямого доступа к прод-Postgres с локалки нет, а одиночный psql -c ломается на
квотинге.

avito_serp_ekb_only=False обязателен: с True парсер выбрасывает всё, где в URL нет
/ekaterinburg/ — из московской выдачи не осталось бы ни одной карточки.

Потолок Авито 30x60=1800 на запрос, поэтому план ценовых коридоров с бисекцией по
ГЕОМЕТРИЧЕСКОЙ середине: цены логнормальны, арифметическая середина 1млн..100млн
даёт вырожденно-пустую верхнюю половину. Коридор, который не влезает в 1800 даже
на минимальной ширине, помечается truncated и недобор пишется в batches.notes —
молчаливое усечение читалось бы как полный охват.

Стоп на первом признаке блока (403/439, 429, firewall, PoW, 0 карточек при ненулевом
счётчике) без ретраев: ретрай по забаненному техаккаунту только углубляет бан.
Дефолт — --measure 100, полный проход только по явному --full.
2026-09-08 22:48:37 +03:00

147 lines
9.7 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`.
Прод-скрейпер, его расписания, прокси-пул и сайдкар **не задействованы вообще**.
Браузер — уже открытый 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-<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`).
## Как проверить залитое
```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/`, включая все
подмосковные слаги — из московской выдачи не осталось бы ничего.