Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой #3562

Merged
bot-backend merged 2 commits from fix/kit-providers-config into main 2026-09-17 09:23:58 +00:00
Collaborator

Closes #2385
Closes #2386

Два хвоста миграции scraper_kit на общие фабрики (#2352, эпик F4). На проде поведение не меняется: все боевые вызовы уже передают config, параметры curl-сессии IMV те же. Правки убирают две ловушки, на которых уже теряли дни.

#2385: config в newbuilding-провайдерах Циана и Яндекса теперь обязателен

Что было. fetch_newbuilding, resolve_cian_zhk_url_via_search, YandexNewbuildingScraper и resolve_yandex_jk_slug принимали config: ScraperConfig | None = None. Без config браузерный фетчер строился в обход фабрики как BrowserFetcher(endpoint=None), а резолвер Циана получал сессию без прокси и без пула. На проде это до сих пор так: в tradein-scraper в /app/packages/scraper-kit/.../cian/newbuilding.py на строках 217 и 886 стоит | None = None.

Почему это важно. Сейчас config передают все боевые вызовы: admin.py (2 места), cian_history_backfill, newbuilding_enrich_backfill, yandex_newbuilding_sweep и pipeline.run_cian_city_sweep. Но любой новый вызов без config молча ушёл бы на env-узел сайдкара мимо пула. Именно так потеряли 8 суток в #2767 и сломались в #3197.

Что сделано. config стал обязательным keyword-only аргументом. Запасные ветки else: BrowserFetcher(source=..., endpoint=None) удалены вместе с # type: ignore[arg-type] и импортом BrowserFetcher. Фетчер теперь строит только build_browser_fetcher. Устаревший комментарий «admin.py зовёт без config» тоже удалён. Боевой код не трогали: config передают все его вызовы.

Тесты. Четыре кейса *_without_config ждали endpoint=None/proxy_url=None, то есть закрепляли ту самую ловушку. Вместо них один параметризованный тест test_newbuilding_entrypoints_refuse_to_run_without_config: вызов без config падает TypeError ещё до сети, а шпионы на _base.BrowserFetcher и build_curl_cffi_session не вызваны ни разу. Остальные тестовые вызовы получили config. Четыре теста #2860 и два в test_scraper_proxy.py теперь подменяют BrowserFetcher в фабрике, а не в модуле провайдера.

#2386: IMV Авито строит curl-сессию общей фабрикой

Что было. avito/imv.py сам собирал curl_cffi.requests.AsyncSession с третьей копией document-заголовков (_DOC_HEADERS). Комментарий NB #2361 F4a объяснял причину: parity-тесты патчили curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля, так что патч до фабрики не долетал.

Почему вариант B, а не A. В issue рекомендован вариант A: живой lookup curl_cffi.requests.AsyncSession в _base. Но в кодовой базе уже прижился патч scraper_kit.providers._base.AsyncSession: test_scraper_proxy.py:293,321, test_pipeline_browser_routing.py:246, test_kit_serp_proxy_pool.py:198-201. Вариант A убрал бы атрибут _base.AsyncSession и сломал бы эти тесты. Вариант B — это следование уже принятому соглашению, а не новое.

Что сделано. Сессия строится через build_document_session(proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC). _DOC_HEADERS удалён, warm-up берёт DOCUMENT_HEADERS. До правки проверил: _DOC_HEADERS == DOCUMENT_HEADERS, совпадает и порядок ключей. Мёртвый try/except ImportError убран: curl_cffi и так импортируется на уровне модуля через _base. Удалён и NB-комментарий.

Тесты. test_avito_imv_kit_parity.py (2 места) и test_3386_estimator_imv_proxy_pool.py (2 места) теперь патчат scraper_kit.providers._base.AsyncSession. Ассерты не ослаблены. Тест с config теперь проверяет ещё impersonate == DEFAULT_IMPERSONATE, timeout == 25 и headers == DOCUMENT_HEADERS, то есть что параметры сессии остались прежними. Без смены цели патча оба теста в test_3386 остались бы зелёными вхолостую: патч перестал бы перехватывать конструктор.

Прогоны

  • Точечно по #2385 (12 файлов, включая test_2767_*, test_2830, test_3394, group_c parity, tasks/test_yandex_newbuilding_sweep, tasks/test_newbuilding_enrich_backfill, test_3197): 110 passed, rc=0.
  • Точечно по #2386 (imv parity, 3386, scraper_proxy, kit_serp_proxy_pool, imv browser transport, impersonate single source, imv parse/status/integration): 86 passed, rc=0.
  • Весь сьют МЕРА backend (DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider): 6225 passed, 42 skipped, rc=0 (126 с). Пакет scraper-kit своих тестов не содержит: uv run python -m pytest -q в packages/scraper-kit → «no tests ran», rc=5; его тесты живут в backend/tests.
  • uv run ruff check app tests и ruff по изменённым файлам кита: All checks passed. ruff format --check по изменённым файлам: чисто.

Фальсификация

#2385. Исходники cian/newbuilding.py и yandex/newbuilding.py подменены версиями из origin/main (копия фикса лежала в scratchpad, после прогона восстановлена, diff -q чистый). Прогон tests/test_scraper_kit_newbuilding_endpoint.py дал 4 failed, 8 passed:

FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[cian_fetch_newbuilding]
E       AssertionError: BrowserFetcher: endpoint не задан
FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[yandex_fetch_jk]
E       Failed: DID NOT RAISE TypeError
FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[resolve_yandex_slug]
E       Failed: DID NOT RAISE TypeError
FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[resolve_cian_url]
E       Failed: DID NOT RAISE TypeError

#2386, мутация 1. В build_document_session(...) вместо _proxy_url подставлен proxy_url=None. Результат 1 failed, 1 passed:

E       AssertionError: assert None == {'http': 'http://test-proxy.local:8080', 'https': 'http://test-proxy.local:8080'}
E        +  where ... = {'impersonate': 'chrome146', 'timeout': 25, 'proxies': None, 'headers': {...}}.get
FAILED tests/scrapers/test_avito_imv_kit_parity.py::test_kit_evaluate_via_imv_with_config_uses_proxy

#2386, мутация 2. imv.py возвращён к версии из origin/main со своей сессией. Результат 2 failed: патч _base.AsyncSession не перехватывает старую конструкцию, запрос уходит в реальную сеть (Could not resolve proxy: test-proxy.local / IMVTransientError). Значит, тесты действительно закрепляют переезд на фабрику. После обеих мутаций исходник восстановлен, diff -q чистый.

Приёмка на проде (после деплоя; проверить до 24.09.2026)

  • #2385, маркер. Проверка: docker exec tradein-scraper grep -c "config: ScraperConfig | None" /app/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py /app/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py. Ожидаю 0 и 0; на текущем проде 2 и 2, так что маркер различает версии.
  • #2385, эффект. Проверка: SELECT id, status, error FROM scrape_runs WHERE source IN ('cian_history_backfill','yandex_newbuilding_sweep','newbuilding_enrich_backfill') AND started_at > <время деплоя> AND coalesce(error,'') ILIKE '%required keyword-only argument%'. Ожидаю 0 строк. Если строка есть, значит пропущен вызывающий, и это ровно то, что правка должна ловить громко.
  • #2386, маркер. Проверка: docker exec tradein-backend grep -c "CffiAsyncSession\|_DOC_HEADERS" /app/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py. Ожидаю 0, сейчас больше нуля.
  • #2386, эффект. Проверка: на первой оценке с промахом кэша IMV в логе tradein-backend после imv: cache MISS идёт IMV warm-up GET /evaluation/realty → HTTP <код> без TypeError/AttributeError. Сессия построена фабрикой и сходила в сеть. Оговорка: за 7 суток до правки в avito_imv_evaluations 0 записей, а в логах с 06:09 17.09 нет ни одного imv: cache MISS. Путь почти не исполняется, так что по записям эффект не измерить. Отсутствие новых ошибок — единственный честный критерий.

#3191 в этот PR не вошёл

Группа задач включала #3191 («вечно недогруженные карточки Яндекса обрывают прогон»). Не сделан, причины ниже.

  • Дефект жив. Прогоны 7198…7281 (16.09) рвутся на тех же пяти listing_id 10776456, 10775964, 10775945, 10775747, 10775594. Прогоны 7321/7345 (17.09) прошли только потому, что бюджет кончился раньше, чем очередь до них дошла. Впереди этих пяти осталось 38 необогащённых объявлений, позади 20 461.
  • Диагноз уточнён. Одна и та же страница offer/7338505269989347072 и offer/67398737598609920, забранная отсюда, весит 381 002 и 380 759 байт, как на проде. В ней нет INITIAL_STATE, нет <title> и ни разу не встречается запрошенный id. Живая карточка для сравнения: 3,3 МБ, id встречается 29 раз. Это не «недогруженная карточка», а оболочка без объявления (похоже на снятое объявление; last_seen_at всех пяти застыл на 12.09 15:02).
  • Почему без решения владельца не обойтись. Такие оболочки не уходят из очереди и копятся в её голове. Любой брейкер с фиксированным порогом рано или поздно упрётся в эту кучу, а без брейкера куча съест бюджет прогона. Нужно состояние по объявлению. Вариантов два: миграция со счётчиком и временем недогруза (план триажа, номер миграции этой группе не выдан) или «оболочка без id оффера = снято» с is_active=FALSE, как avito 404. Второе — новая запись в данные, её должен одобрить владелец.

🤖 Generated with Claude Code

Closes #2385 Closes #2386 Два хвоста миграции scraper_kit на общие фабрики (#2352, эпик F4). На проде поведение не меняется: все боевые вызовы уже передают config, параметры curl-сессии IMV те же. Правки убирают две ловушки, на которых уже теряли дни. ## #2385: config в newbuilding-провайдерах Циана и Яндекса теперь обязателен **Что было.** `fetch_newbuilding`, `resolve_cian_zhk_url_via_search`, `YandexNewbuildingScraper` и `resolve_yandex_jk_slug` принимали `config: ScraperConfig | None = None`. Без config браузерный фетчер строился в обход фабрики как `BrowserFetcher(endpoint=None)`, а резолвер Циана получал сессию без прокси и без пула. На проде это до сих пор так: в `tradein-scraper` в `/app/packages/scraper-kit/.../cian/newbuilding.py` на строках 217 и 886 стоит `| None = None`. **Почему это важно.** Сейчас config передают все боевые вызовы: admin.py (2 места), cian_history_backfill, newbuilding_enrich_backfill, yandex_newbuilding_sweep и pipeline.run_cian_city_sweep. Но любой новый вызов без config молча ушёл бы на env-узел сайдкара мимо пула. Именно так потеряли 8 суток в #2767 и сломались в #3197. **Что сделано.** config стал обязательным keyword-only аргументом. Запасные ветки `else: BrowserFetcher(source=..., endpoint=None)` удалены вместе с `# type: ignore[arg-type]` и импортом `BrowserFetcher`. Фетчер теперь строит только `build_browser_fetcher`. Устаревший комментарий «admin.py зовёт без config» тоже удалён. Боевой код не трогали: config передают все его вызовы. **Тесты.** Четыре кейса `*_without_config` ждали `endpoint=None`/`proxy_url=None`, то есть закрепляли ту самую ловушку. Вместо них один параметризованный тест `test_newbuilding_entrypoints_refuse_to_run_without_config`: вызов без config падает `TypeError` ещё до сети, а шпионы на `_base.BrowserFetcher` и `build_curl_cffi_session` не вызваны ни разу. Остальные тестовые вызовы получили config. Четыре теста #2860 и два в test_scraper_proxy.py теперь подменяют `BrowserFetcher` в фабрике, а не в модуле провайдера. ## #2386: IMV Авито строит curl-сессию общей фабрикой **Что было.** `avito/imv.py` сам собирал `curl_cffi.requests.AsyncSession` с третьей копией document-заголовков (`_DOC_HEADERS`). Комментарий NB #2361 F4a объяснял причину: parity-тесты патчили `curl_cffi.requests.AsyncSession`, а `_base` импортирует класс на уровне модуля, так что патч до фабрики не долетал. **Почему вариант B, а не A.** В issue рекомендован вариант A: живой lookup `curl_cffi.requests.AsyncSession` в `_base`. Но в кодовой базе уже прижился патч `scraper_kit.providers._base.AsyncSession`: test_scraper_proxy.py:293,321, test_pipeline_browser_routing.py:246, test_kit_serp_proxy_pool.py:198-201. Вариант A убрал бы атрибут `_base.AsyncSession` и сломал бы эти тесты. Вариант B — это следование уже принятому соглашению, а не новое. **Что сделано.** Сессия строится через `build_document_session(proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC)`. `_DOC_HEADERS` удалён, warm-up берёт `DOCUMENT_HEADERS`. До правки проверил: `_DOC_HEADERS == DOCUMENT_HEADERS`, совпадает и порядок ключей. Мёртвый `try/except ImportError` убран: curl_cffi и так импортируется на уровне модуля через `_base`. Удалён и NB-комментарий. **Тесты.** test_avito_imv_kit_parity.py (2 места) и test_3386_estimator_imv_proxy_pool.py (2 места) теперь патчат `scraper_kit.providers._base.AsyncSession`. Ассерты не ослаблены. Тест с config теперь проверяет ещё `impersonate == DEFAULT_IMPERSONATE`, `timeout == 25` и `headers == DOCUMENT_HEADERS`, то есть что параметры сессии остались прежними. Без смены цели патча оба теста в test_3386 остались бы зелёными вхолостую: патч перестал бы перехватывать конструктор. ## Прогоны - Точечно по #2385 (12 файлов, включая test_2767_*, test_2830, test_3394, group_c parity, tasks/test_yandex_newbuilding_sweep, tasks/test_newbuilding_enrich_backfill, test_3197): **110 passed**, rc=0. - Точечно по #2386 (imv parity, 3386, scraper_proxy, kit_serp_proxy_pool, imv browser transport, impersonate single source, imv parse/status/integration): **86 passed**, rc=0. - Весь сьют МЕРА backend (`DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider`): **6225 passed, 42 skipped**, rc=0 (126 с). Пакет scraper-kit своих тестов не содержит: `uv run python -m pytest -q` в `packages/scraper-kit` → «no tests ran», rc=5; его тесты живут в `backend/tests`. - `uv run ruff check app tests` и ruff по изменённым файлам кита: All checks passed. `ruff format --check` по изменённым файлам: чисто. ## Фальсификация **#2385.** Исходники `cian/newbuilding.py` и `yandex/newbuilding.py` подменены версиями из origin/main (копия фикса лежала в scratchpad, после прогона восстановлена, `diff -q` чистый). Прогон `tests/test_scraper_kit_newbuilding_endpoint.py` дал `4 failed, 8 passed`: ``` FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[cian_fetch_newbuilding] E AssertionError: BrowserFetcher: endpoint не задан FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[yandex_fetch_jk] E Failed: DID NOT RAISE TypeError FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[resolve_yandex_slug] E Failed: DID NOT RAISE TypeError FAILED ...::test_newbuilding_entrypoints_refuse_to_run_without_config[resolve_cian_url] E Failed: DID NOT RAISE TypeError ``` **#2386, мутация 1.** В `build_document_session(...)` вместо `_proxy_url` подставлен `proxy_url=None`. Результат `1 failed, 1 passed`: ``` E AssertionError: assert None == {'http': 'http://test-proxy.local:8080', 'https': 'http://test-proxy.local:8080'} E + where ... = {'impersonate': 'chrome146', 'timeout': 25, 'proxies': None, 'headers': {...}}.get FAILED tests/scrapers/test_avito_imv_kit_parity.py::test_kit_evaluate_via_imv_with_config_uses_proxy ``` **#2386, мутация 2.** `imv.py` возвращён к версии из origin/main со своей сессией. Результат `2 failed`: патч `_base.AsyncSession` не перехватывает старую конструкцию, запрос уходит в реальную сеть (`Could not resolve proxy: test-proxy.local` / `IMVTransientError`). Значит, тесты действительно закрепляют переезд на фабрику. После обеих мутаций исходник восстановлен, `diff -q` чистый. ## Приёмка на проде (после деплоя; проверить до 24.09.2026) - **#2385, маркер.** Проверка: `docker exec tradein-scraper grep -c "config: ScraperConfig | None" /app/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py /app/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py`. Ожидаю `0` и `0`; на текущем проде `2` и `2`, так что маркер различает версии. - **#2385, эффект.** Проверка: `SELECT id, status, error FROM scrape_runs WHERE source IN ('cian_history_backfill','yandex_newbuilding_sweep','newbuilding_enrich_backfill') AND started_at > <время деплоя> AND coalesce(error,'') ILIKE '%required keyword-only argument%'`. Ожидаю 0 строк. Если строка есть, значит пропущен вызывающий, и это ровно то, что правка должна ловить громко. - **#2386, маркер.** Проверка: `docker exec tradein-backend grep -c "CffiAsyncSession\|_DOC_HEADERS" /app/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py`. Ожидаю `0`, сейчас больше нуля. - **#2386, эффект.** Проверка: на первой оценке с промахом кэша IMV в логе `tradein-backend` после `imv: cache MISS` идёт `IMV warm-up GET /evaluation/realty → HTTP <код>` без TypeError/AttributeError. Сессия построена фабрикой и сходила в сеть. Оговорка: за 7 суток до правки в `avito_imv_evaluations` 0 записей, а в логах с 06:09 17.09 нет ни одного `imv: cache MISS`. Путь почти не исполняется, так что по записям эффект не измерить. Отсутствие новых ошибок — единственный честный критерий. ## #3191 в этот PR не вошёл Группа задач включала #3191 («вечно недогруженные карточки Яндекса обрывают прогон»). Не сделан, причины ниже. - Дефект жив. Прогоны 7198…7281 (16.09) рвутся на тех же пяти listing_id 10776456, 10775964, 10775945, 10775747, 10775594. Прогоны 7321/7345 (17.09) прошли только потому, что бюджет кончился раньше, чем очередь до них дошла. Впереди этих пяти осталось 38 необогащённых объявлений, позади 20 461. - Диагноз уточнён. Одна и та же страница `offer/7338505269989347072` и `offer/67398737598609920`, забранная отсюда, весит 381 002 и 380 759 байт, как на проде. В ней нет `INITIAL_STATE`, нет `<title>` и ни разу не встречается запрошенный id. Живая карточка для сравнения: 3,3 МБ, id встречается 29 раз. Это не «недогруженная карточка», а оболочка без объявления (похоже на снятое объявление; `last_seen_at` всех пяти застыл на 12.09 15:02). - Почему без решения владельца не обойтись. Такие оболочки не уходят из очереди и копятся в её голове. Любой брейкер с фиксированным порогом рано или поздно упрётся в эту кучу, а без брейкера куча съест бюджет прогона. Нужно состояние по объявлению. Вариантов два: миграция со счётчиком и временем недогруза (план триажа, номер миграции этой группе не выдан) или «оболочка без id оффера = снято» с `is_active=FALSE`, как avito 404. Второе — новая запись в данные, её должен одобрить владелец. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 2 commits 2026-09-17 08:04:51 +00:00
fetch_newbuilding, resolve_cian_zhk_url_via_search, YandexNewbuildingScraper и
resolve_yandex_jk_slug принимали config=None и в этом случае строили
BrowserFetcher(endpoint=None) мимо фабрики, а резолвер Циана — сессию без прокси.
Все боевые вызовы config уже передают, но новый вызывающий без config молча ушёл бы
на env-узел сайдкара мимо пула — именно так потеряли 8 суток в #2767 и сломались
в #3197. Теперь config обязателен по сигнатуре, запасные ветки удалены, фетчер
строит только build_browser_fetcher.

Тесты: кейсы *_without_config (ждали endpoint=None) заменены одним — без config
TypeError до сети, фетчер и сессия не строятся; остальные тестовые вызовы
получили config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refactor(tradein): IMV Авито строит curl-сессию общей фабрикой, а не своей копией (#2386)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m55s
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
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
808a4fb4cb
avito/imv.py сам собирал curl_cffi AsyncSession с третьей копией
document-заголовков (_DOC_HEADERS) — из-за того, что parity-тесты патчили
curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля и
такой патч до него не долетает. Взят вариант B из issue: в кодовой базе уже
принят патч scraper_kit.providers._base.AsyncSession (test_scraper_proxy.py,
test_pipeline_browser_routing.py, test_kit_serp_proxy_pool.py), а вариант A
(живой lookup в _base) сломал бы эти тесты — атрибута _base.AsyncSession не стало бы.

Сессия теперь build_document_session(proxy_url, timeout=25); _DOC_HEADERS
удалён (идентичен DOCUMENT_HEADERS, проверено до правки), мёртвый
try/except ImportError убран — curl_cffi и так импортируется через _base.
Тесты патчат _base.AsyncSession; ассерт с config дополнительно фиксирует
impersonate, timeout=25 и заголовки — параметры сессии не изменились.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 10c80e9e1d into main 2026-09-17 09:23:58 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3562
No description provided.