Трек 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.
147 lines
9.7 KiB
Markdown
147 lines
9.7 KiB
Markdown
# Локальный сбор 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` 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-<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/`, включая все
|
||
подмосковные слаги — из московской выдачи не осталось бы ничего.
|