Живая проверка в браузере (клик по строке "Предыдущие оценки" на проде)
подтвердила: router.replace() — асинхронный client-nav (RSC round-trip),
window.location.search не обновляется синхронно, а readUrlId() читает
window.location.search напрямую (useSearchParams намеренно не используется —
ломает next build через Suspense). Единственный надёжный источник urlId
в синхронном ре-рендере — override-state, выставленный в том же тике,
что и клик.
Предыдущая версия (`pendingRestoreId ?? readUrlId()`) использовала null
для двух разных смыслов сразу: "нет override" и "принудительно пусто".
handleNew() выставлял null именно во втором смысле (сброс после 404),
но ?? тут же трактовал null как "нет override" и падал обратно на
readUrlId() — который на тот момент ещё возвращал старый плохой id
(router.replace("/v2") ещё не успел домотать). Итог: "НОВАЯ ОЦЕНКА"
после битой ссылки могла не сбрасывать ошибку.
Три состояния убирают двусмысленность: undefined = нет override (readUrlId
рулит), null = принудительно пусто, string = override на конкретный id.
Иначе клик "НОВАЯ ОЦЕНКА" после ошибки восстановления (напр. 404 на битый history-id) навсегда
залипает на старом pendingRestoreId — единственный effect, который его чистит, требует
restored.data, которого в error-состоянии не будет никогда. Только F5 разлипал бы.
router.replace(`/v2?id=`) — асинхронный soft-nav, а setNav(0) в том же тике вызывает
синхронный re-render ДО того как window.location реально обновится → readUrlId() читает
устаревшее значение навсегда (никто больше не форсит re-render). Добавлен pendingRestoreId —
синхронный override, аналогично тому как freshResult уже обходит эту же гонку для
post-submit пути.
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5)
убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main.
Заодно:
- удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/),
импортировавших уже-удалённые или удаляемые сейчас legacy-модули
- тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET
тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.*
эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete
golden-parity тестов, потерявших legacy-оракл
- kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config —
ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(),
MagicMock HouseMatcher, region_code=66)
Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка
#2208 (test_search_cache_hit, не связан со scrapers).
Deep-review нашёл: удалённый test_kit_registry_completeness.py заменялся
test_routing_coverage_sets_match, но тот строил registry ИЗ хардкоженного
_PRODUCT_SOURCES через recording-stub и проверял тот же набор — тавтология,
не гоняющая реальный build_product_handlers(). Плюс _PRODUCT_SOURCES был
устаревшим: не хватало cian_history_backfill, deals_freshness_monitor,
osm_poi_ekb_refresh (все три есть в реальном build_product_handlers).
Фикс:
- _PRODUCT_SOURCES дополнен тремя пропущенными source'ами, сверен построчным
grep '"...": Handler(' по product_handlers.py (20 exact + 1 wildcard).
Список остаётся canonical hardcode (источник правды — сам product_handlers.py
+ scrape_schedules seed-миграции), с явным комментарием "поддерживается вручную".
- test_routing_coverage_sets_match удалён, заменён
test_real_build_product_handlers_covers_all_scheduled_sources: зовёт РЕАЛЬНЫЙ
product_handlers.build_product_handlers(ctx=None) + build_registry (не stub),
ассертит resolve_handler для каждого source из _PRODUCT_SOURCES ∪
_KIT_NATIVE_SOURCES. Уроненная handler-entry или scheduled source без
handler'а теперь роняет CI.
Регресс-пруф (сделан вручную, откачен): временно закомментировал
"osm_poi_ekb_refresh" в build_product_handlers → новый тест упал с
"misses source=osm_poi_ekb_refresh"; вернул строку → тест снова зелёный
(git diff на product_handlers.py пуст после отката).
Verify: full pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208); ruff 0.7.4 чист на изменённых файлах.
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.
app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).
main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.
scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.
app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).
Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.
Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
После миграции admin.py на scraper_kit.orchestration.pipeline (предыдущий
коммит) 4 endpoint-теста продолжали патчить устаревший
app.services.scrape_pipeline.run_* — имя, на которое admin.py больше не
ссылается. Мок не перехватывал вызов → FastAPI BackgroundTask реально
запускал kit-sweep (сетевой I/O), что локально (без :5432) тихо падало
на подключении к БД и глоталось except'ом, а на CI грозило зависанием/таймаутом.
Перенацелены patch-таргеты на app.api.v1.admin.run_* (namespace, куда
забинжен kit-импорт):
- test_city_sweep.py::test_start_endpoint_ok -> run_avito_city_sweep
- test_cian_city_sweep.py::test_cian_start_endpoint_ok -> run_cian_city_sweep
- test_yandex_city_sweep.py::test_yandex_start_endpoint_ok -> run_yandex_city_sweep
- test_sweep_imv_phase.py::test_sweep_endpoint_passes_enrich_imv -> run_avito_city_sweep
Добавлены DI-guard assertions (закрывают coverage-gap: раньше проверялся
только HTTP 200, не факт вызова kit с правильным DI) — mock_sweep.call_args.kwargs
проверяется на RealScraperConfig/RealMatcherAdapter/RealEnrichmentJobs (там, где
kit-сигнатура их требует) + наличие proxy_provider.
Мигрирует 5 admin debug-роутов с legacy app.services.scrape_pipeline на
scraper_kit.orchestration.pipeline (эпик #2277 decommission, umbrella #2397):
- POST /scrape/avito-city-sweep -> run_avito_city_sweep
- POST /scrape/cian-city-sweep -> run_cian_city_sweep
- POST /scrape/cian-full-load -> run_cian_full_load
- POST /scrape/yandex-city-sweep -> run_yandex_city_sweep
- POST /scrape/yandex-full-load -> run_yandex_full_load
DI-паттерн зеркалит app.scheduler_main._run_kit_scheduler /
scraper_kit.orchestration.scheduler._job_* (уже используется в этом же файле
для Group A #2305 debug-роутов): config=RealScraperConfig(),
matcher=RealMatcherAdapter(), proxy_provider=_kit_proxy_provider() для всех
пяти + enrichment=RealEnrichmentJobs() там, где kit-сигнатура его требует
(avito_city_sweep, yandex_city_sweep, yandex_full_load — cian-варианты
enrichment не принимают). shutdown_requested не прокидывается — у admin
BackgroundTasks нет SIGTERM-drain семантики, дефолт kit (lambda: False)
эквивалентен прежнему поведению.
Разблокирует финальное удаление зависимости admin.py от scrape_pipeline.py.
Production-scheduler (app/services/scheduler.py, USE_KIT_SCHEDULER=False
ship-dark) остаётся на legacy — это отдельный, более крупный шаг миграции
SERP-оркестрации.
app/tasks/avito_detail_backfill.py imported _CHROME_HEADERS/_avito_proxies from
the legacy app/services/scrape_pipeline.py (the only two symbols it needed from
that module). scrape_pipeline.py is slated for wholesale deletion in Part E of
epic #2277 -- this severs the last dependency so that deletion won't break the
backfill task.
Source chosen: kit-reuse, not relocate. scraper_kit/providers/_base.py (#2358
Foundation) already extracted the identical building blocks while deduplicating
providers/avito/{serp,detail,imv}.py:
- DOCUMENT_HEADERS -- byte-identical dict to _CHROME_HEADERS (same 8 keys/values,
Accept/Accept-Language/Cache-Control/Sec-Fetch-*/Upgrade-Insecure-Requests).
- http_proxies(proxy_url) -- same formula as _avito_proxies():
{"http": url, "https": url} if url else None, just parameterized instead of
reading settings.scraper_proxy_url internally (both resolve the identical
settings singleton in production, so behavior is unchanged).
Kept the AsyncSession(...) construction inline (didn't switch to kit's
build_document_session helper) to preserve the existing
`app.tasks.avito_detail_backfill.AsyncSession` mock seam used by
tests/tasks/test_avito_detail_backfill.py -- swapping to the helper would call
curl_cffi's AsyncSession via scraper_kit.providers._base instead, silently
bypassing that patch target.
scrape_pipeline.py itself is untouched (kept its own copies, per Part E plan).
Full backend suite: 3276 passed, 1 known-unrelated fail (test_search_cache_hit,
#2208), 6 skipped.
F4c-yandex (epic #2277 Group F, parent #2352): migrate the SERP scraper's
BrowserFetcher construction onto build_browser_fetcher() from the Foundation
module (#2358, providers/_base.py) instead of local ad-hoc construction.
- yandex/serp.py::__aenter__ now calls build_browser_fetcher(config, source=,
proxy_provider=, fetch_timeout_s=) instead of constructing BrowserFetcher(...)
directly. Preserves the existing fetch_timeout_s=30 (see caveat below),
endpoint/proxy_provider/use_pool resolution byte-for-byte. The retry/tarpit
logic in fetch_around() (rotate_ip + sleep on status==0/JSON-error) is
untouched, per the Foundation module's documented exclusion.
Left unchanged (deliberate, not an oversight):
- yandex/serp.py::_rotate_ip's curl_cffi AsyncSession(timeout=30) — this hits
the mobile-proxy provider's own changeip API, not Yandex; it intentionally
carries no impersonate/headers/proxy (same as the mirrored avito/serp.py
rotate-ip session). Not listed among the _base.py docstring's documented
extraction sites -- consistent exclusion, not a new one.
- yandex/detail.py -- no BrowserFetcher/curl_cffi construction exists in this
file (uses BaseScraper's httpx-based _http_get). Nothing to migrate.
- yandex/newbuilding.py -- both BrowserFetcher(...) call sites take an
OPTIONAL `config: ScraperConfig | None`, a genuinely different contract from
build_browser_fetcher's mandatory `config`. Migrating would require making
config mandatory (breaking admin.py:1511, yandex_newbuilding_sweep.py:353,
and several tests that call without config -- out of scope for a call-site
swap) and breaks test_scraper_kit_newbuilding_endpoint.py's "without config,
endpoint=None" regression case (its SimpleNamespace mock lacks
use_proxy_pool_browser, and the mock config path can never satisfy a
mandatory-config helper). Left untouched; the class-bug #2322/#2330 this
file already fixed (config now threaded, just not via the shared helper)
remains fixed.
Also updates test_kit_serp_proxy_pool.py's 3 yandex-specific tests: they
monkeypatched the module-local `yandex_serp.BrowserFetcher` name, which the
migrated call path no longer references directly (it now goes through
scraper_kit.providers._base.build_browser_fetcher -> _base's own BrowserFetcher
import). Patch target moved to scraper_kit.providers._base.BrowserFetcher;
every assertion is unchanged -- this adapts the mock to the new (intentional)
call path, it does not weaken what the test verifies. Cian's tests in the same
file are untouched (cian/serp.py is not migrated yet, separate F4b issue).