Classify tradein-browser sidecar HTTP 500/503 in _fetch_serp_html_browser and route into existing rotation/retry machinery: soft-ban → IP rotation, transient → bounded backoff, both exhausted → AvitoRateLimitedError (graceful mark_banned, partial preserved) instead of mark_failed. Fixes run-#356 class where one late 500 nuked a run that had saved 3186 listings.
Refs #1790
FIX 1: в _parse_html добавлен гео-фильтр per-card source_url. При
avito_serp_ekb_only=True (ENV AVITO_SERP_EKB_ONLY, дефолт True) карточки без
/ekaterinburg/ в source_url отбрасываются — Авито добивает выдачу
«по всей России» при малом числе ЕКБ-результатов (rooms=4 давал 96% не-ЕКБ).
Карточки без распознанного city-slug пропускаются консервативно.
Лог: «avito SERP: dropped N non-EKB cards (padding)».
FIX 2 (анализ): ROOM_SLUGS уже содержит отдельные бакеты «4-комн.» и «5+комн.»
(разбиты ранее). rooms-значение берётся из текста заголовка карточки
(_extract_rooms_from_title: regex «(\d)-к. квартира»), а не из бакета —
4/5/6 различаются по факту парсинга. Сплит slug'ов был сделан ранее, регрессий нет.
Тесты: 4 новых unit-теста в test_avito_ekb_geo_filter.py покрывают:
EKB+non-EKB → только EKB; ekb_only=False → все проходят;
без city-slug → консервативно оставляем; все EKB → ничего не дропается.
Avito leaks addresses with house number glued to district marker without
separator («…Савкова, 29р-н Академический»), breaking house-number extraction
and same-house matching (41 unmatched avito listings on one street).
_clean_address now inserts ", " between a house-number token (digits +
optional Cyrillic letter + optional /N|сN|кN|корпус|стр) and a following
marker (р-н, мкр, район, г., пос, снт, …).
- avito.py: idempotent _deglue_house_marker step (CSS/tail-strip kept)
- tests: de-glue positive/negative/idempotency (24 passed)
- data/sql/124_deglue_avito_addresses.sql: idempotent backfill for stored rows
normalize.py НЕ тронут (общий fingerprint матчинга, #1534).
Закрывает avito-часть #1773 (cian-без-номера + дедуп — отдельно, зона cian).
Run 216 recovered 403s (merged fix) but still banned on a 429 at page 36
(deep pagination, backconnect conn-limit). Widen the 429 retry budget + one
reconnect on exhaustion in _fetch_serp_html, and make fetch_all_secondary skip
a blocked bucket and continue rather than aborting the run; only N consecutive
blocked buckets (real hard ban) re-raises. Preserves hard-ban detection.
Adds since=date mode to fetch_all_secondary: per room x seed-bracket, paginate
sequentially newest-first and stop once cards fall below the watermark, skipping
deep pagination (and the 403 clusters it triggers). since=None keeps the exact
exhaustive bisection walk. No scheduler/schedule change here — wiring follows
in a separate PR.
PR #1764's reconnect-retry was dead code in prod: the gate excluded configs
with avito_proxy_rotate_url set, but prod has BOTH the backconnect SCRAPER_PROXY_URL
(what curl_cffi egresses through) AND a leftover changeip URL for the separate
auv/browser proxy. With max_rotations=0 neither path fired -> a single SERP 403
still aborted the sweep (run 212). The curl session always uses scraper_proxy_url,
so session-rebuild rotates the exit IP regardless of changeip config.
Under a backconnect rotating proxy (no changeip URL) a single HTTP 403 or
firewall interstitial aborted the entire avito_full_load sweep via
AvitoBlockedError -> mark_banned. Each new connection through backconnect
gets a fresh exit IP, so retry by rebuilding the curl_cffi session up to
_AVITO_403_MAX_RETRIES times before giving up. Legacy single-IP changeip
rotation and browser-mode paths unchanged. Mirrors existing 429 short-retry.
Route each scraper source (avito/cian/yandex/domclick) to its own camoufox
browser+proxy so they no longer wedge each other through a single global egress.
Feature-flagged (FEATURE_BROWSER_POOL_ENABLED, default OFF): with the flag off the
/fetch and /login paths are byte-for-byte the existing single-browser behavior.
When on, /fetch routes by body["source"] to a per-proxy browser via BROWSER_PROXY_MAP
(BROWSER_PROXY_AVITO/CIAN/YANDEX/DOMCLICK with legacy fallbacks), each guarded by its
own lazy-launched lock. /login stays single-browser in Phase 1.
BrowserFetcher gains a source arg (default avito) and sends it in the /fetch body;
all scraper callsites pass their source. No docker-compose/.env.runtime changes
(Phase 2, owner-gated).
Add global request delay floor + per-source delay management.
- 054: seed 'global'=0 + per-source rows (avito=7, cian=5, n1=5, domrf=5, rosreestr=5)
using ON CONFLICT DO NOTHING; reuses existing 053 table from #484
- scraper_settings.py: get_scraper_delay() = max(per_source, global); _GLOBAL_KEY,
refactored _get_setting_cached(); preserved yandex umbrella alias logic
- admin.py: GET /scraper-settings + PUT /scraper-settings/{source} (ge=0.0, le=60.0);
cache invalidation on update; CAST(:d AS numeric) per psycopg v3
- avito/cian/n1 scrapers wired (yandex was wired by #484)
- 18 unit tests pass (10 original updated for max() + 8 new global delay tests)
Closes follow-up gap from #486 (Cian admin slider min=0 now accepted by API).
DOM-путь (_dom_card_to_lot) — основной для Avito: mobile API отдаёт
бан, формат __initialState__ устарел, так что почти все объявления
идут через него. Но он не извлекал фото вообще, а адрес брал мёртвым
селектором [class*="geo-address"] — поэтому объявления Avito оставались
без фото (серый блок в Сводке) и с заглушкой «Екатеринбург (Avito)».
Теперь:
- фото — из <img itemprop="image"> карточки-слайдера (до 5 шт);
- адрес — первый <p> внутри data-marker="item-location" (улица + дом).
Селекторы проверены на живой выдаче Avito.