Contact capture (phone + consent) from estimate result / landing page.
New trade_in_leads table (migration 172), estimate_id FK validated
before insert (404 if unknown). Notification (Telegram/email)
intentionally out of scope — no existing SMTP/Telegram integration
anywhere in the codebase (same gap as pilot.py's TODO).
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).
Мигрирует avito/{serp,detail}.py на build_browser_fetcher()/
build_document_session() из scraper_kit.providers._base (Foundation #2358):
- serp.py: BrowserFetcher(source="avito", endpoint=...) в __aenter__ ->
build_browser_fetcher(config, "avito"); _build_cffi_session() (25s
timeout, document headers, scraper_proxy_url) -> build_document_session().
- detail.py: _build_detail_session() (тот же document-headers dict,
25s timeout) -> build_document_session(proxy_url=..., timeout=25).
Caveat (#2361, проверено explicit): serp.py __aenter__ раньше конструировал
BrowserFetcher БЕЗ proxy_provider/use_pool (implicit None/False). После
миграции use_pool читает config.use_proxy_pool_browser вместо hardcoded
False - НО proxy_provider остаётся None (build_browser_fetcher default), а
BrowserFetcher._use_pool гейтится `and self._proxy_provider is not None`
(browser_fetcher.py:215) - при proxy_provider=None use_pool всегда
эффективно False независимо от config. Verified: behavior-preserving,
не тихая смена прод-поведения.
imv.py: НЕ мигрирован (единственное отклонение от issue). Own-session
curl_cffi construction (headers=_DOC_HEADERS - тот же дублированный dict)
технически подходит под миграцию, но build_document_session() резолвит
AsyncSession через module-level импорт _base.py (biндится один раз при
первом импорте _base), а tests/scrapers/test_avito_imv_kit_parity.py
(#2334) патчит curl_cffi.requests.AsyncSession через unittest.mock -
это работает только с локальным call-time импортом (как было). Миграция
роняет 2 реальных теста (test_kit_evaluate_via_imv_{with,without}_config_
uses_proxy) - не шум, зелёные на main. Прод-поведение идентично в обоих
вариантах, но задача явно запрещает weaken/skip parity-тесты - оставлено
как есть + inline-комментарий с находкой. Follow-up для будущих групп
(cian/yandex/domclick F4b-d): тот же паттерн теста сломает миграцию любого
provider с аналогичным unittest.mock.patch("curl_cffi.requests.AsyncSession").
Tests: full backend suite 3278 passed, 1 known pre-existing flake
(test_search_api.py::test_search_cache_hit, #2208), 6 skipped. Все avito
kit_parity/golden_parity тесты + test_scraper_kit_pipeline_parity2.py
(cross-provider) зелёные.
Мигрирует call-site'ы cian/{serp,detail,newbuilding,session}.py на shared
фабрики Foundation-модуля (#2358): build_browser_fetcher() (serp.py __aenter__)
и build_curl_cffi_session() (detail.py own-session path, newbuilding.py
resolve_cian_zhk_url[_via_search], session.py verify_session). Headers/timeout/
proxy-resolution сохранены байт-в-байт — чистый call-site swap, без изменения
поведения.
Caveat (config Optional footgun): newbuilding.py::fetch_newbuilding() НЕ
переведена на build_browser_fetcher() — build_browser_fetcher() требует
ScraperConfig mandatory (весь смысл фабрики, закрывает класс багов #2322/#2330),
но здесь config: ScraperConfig | None = None легитимно Optional (admin.py
debug-роут и test_scraper_kit_newbuilding_endpoint.py вызывают функцию без
config= и ожидают graceful endpoint=None). Подмена уронила бы AttributeError
на config=None вместо текущего degrade — реальная регрессия, не call-site swap.
Оставлено как есть с комментарием в коде.
Companion test updates (не ослабление, retarget под рефактор): CianScraper
теперь строит BrowserFetcher внутри _base.py, а не в serp.py — два теста в
test_kit_serp_proxy_pool.py, патчившие cian_serp.BrowserFetcher напрямую,
переключены на scraper_kit.providers._base.BrowserFetcher (тот же инвариант,
другая точка патча). Аналогично test_scraper_kit_pricehistory_session_parity.py
::test_fetch_detail_own_session_proxy_wiring_parity — патч AsyncSession
переключён с cian.detail на _base (тот же assert на итоговый proxies=dict).
Tests: full backend suite 3278 passed, 1 pre-existing flake
(test_search_api.py::test_search_cache_hit, #2208), 6 skipped. Все
parity/regression тесты (test_scraper_kit_pipeline_parity2.py,
test_scraper_kit_newbuilding_endpoint.py, test_scraper_kit_cian_golden_parity.py,
test_kit_serp_proxy_pool.py, test_scraper_kit_pricehistory_session_parity.py,
test_cian_serp_scraper.py, test_cian_session.py, test_cian_detail.py) зелёные.
ruff check + format --check чисто на всех изменённых файлах.
Group F4d (epic #2277, parent #2352). Migrated the ad-hoc BrowserFetcher
construction in providers/domclick/serp.py::fetch_city to the shared
build_browser_fetcher(config, source) helper from Foundation (#2358).
Scope actually touched:
- serp.py: replaced the local BrowserFetcher(source="domclick", endpoint=...)
construction with build_browser_fetcher(self._config, "domclick").
- detail.py: NO changes needed. Grepped for BrowserFetcher(/AsyncSession(/
curl_cffi and found none - fetch_detail() receives an already-constructed
browser_fetcher from the caller (orchestrator owns the session lifecycle,
per its own module docstring), so there was nothing to migrate here.
Caveat verified (same class as avito, per issue instructions): the old
call site omitted use_pool, so it implicitly defaulted to False via the
BrowserFetcher class default. build_browser_fetcher(config, source) reads
use_pool=config.use_proxy_pool_browser instead - a real difference in the
value passed in. However this call site does not pass proxy_provider
(stays the default None), and BrowserFetcher._pool_proxy computes
`use_pool and proxy_provider is not None` before ever using the pool - so
with proxy_provider=None the effective behavior is identical regardless
of what use_pool evaluates to (confirmed by reading browser_fetcher.py
directly, matching the build_browser_fetcher docstring's own note that
domclick is one of the providers not yet wired to the proxy pool, #2160
P4). No observable behavior change. Documented inline at the call site.
Tests: domclick-specific suite (91 tests: golden parity, sweep, admin
ingest parity, detail + detail kit parity) all green, plus cross-provider
test_scraper_kit_pipeline_parity2.py (8 tests). Full backend suite:
3278 passed, 6 skipped, 1 pre-existing flake (test_search_cache_hit,
#2208) - no new failures.
code-reviewer отметил: httpx>=0.28 больше не принимает dict в proxies= (только
одиночный proxy= строкой). Docstring ошибочно называл эту функцию
"httpx-совместимой" — уточнено что это только для curl_cffi, чтобы будущий
мигратор F4a-d не словил TypeError.
Найдено при code-review этого же PR (F1, #2349) и независимо подтверждено
F3-аудитом (#2351): run_cian_city_sweep's houses-фаза звала
fetch_newbuilding(zhk_url) без config=, хотя config — mandatory параметр
enclosing-функции, тривиально в scope. Сейчас dormant (USE_KIT_SCHEDULER=False +
cian_city_sweep.enabled=false в SQL seed), но при включении флага/расписания
каждый house молча падал бы AssertionError (перехватывается except → houses_failed,
sweep не крашится целиком, но newbuilding-enrichment был бы 100% failing silently).
code-reviewer отметил: формулировка "PROD DEFAULT" в докстрингах могла ввести в
заблуждение — прод-путь сегодня всё ещё легаси (avito_detail_backfill.py
импортирует build_warmed_session из legacy-модуля), этот фикс — advance-prep,
не изменение живого прод-поведения.
Group E4 (final, highest-risk step of scraper_kit migration epic #2277):
estimator.py and house_imv_backfill.py's avito_imv/cian_valuation/
yandex_valuation call sites now import from scraper_kit.providers.* instead
of app.services.scrapers.*, following the exact wiring proven safe by E1/E2/E3
(#2334/#2335/#2336):
- estimator.py's avito IMV (_get_or_fetch_imv_cached, both call sites) and
house_imv_backfill.py's _process_one_house: add config=RealScraperConfig()
— kit's evaluate_via_imv silently drops the configured proxy without it.
- estimator.py's cian valuation (Stage 9): add config=RealScraperConfig() —
mandatory kwarg on the kit function (TypeError if omitted).
- estimator.py's yandex valuation: add config=RealScraperConfig() (mandatory)
and delay_provider=get_scraper_delay — without it kit silently falls back
to a hardcoded 5.0s throttle instead of the DB-configured anti-ban delay.
- All exception classes imported consistently from the same kit module as
evaluate_via_imv (not just the function) — mixing legacy/kit exception
classes would break `except IMVAddressNotFoundError` etc. via identity
mismatch (caught by an existing test that assumed the legacy class,
fixed alongside).
Observability: both cian_valuation and yandex_valuation graceful-degradation
except-blocks upgraded from logger.warning to logger.exception. GlitchTip's
LoggingIntegration listens at event_level=ERROR (main.py/scheduler_main.py) —
a WARNING never reaches GlitchTip as an event regardless of exc_info, so a
future config-wiring mistake at these call sites needs ERROR level to be
visible in monitoring.
house_imv_backfill.py: RealScraperConfig is imported lazily inside
_process_one_house (not at module level) to avoid a circular import —
app.services.scraper_adapters imports backfill_house_imv/
process_houses_imv_batch from this module at module level. Verified via
direct import in both orders plus a full `app.main` import.
Also fixes a stale docstring claiming process_houses_imv_batch is "not wired
into scheduler" — it is, via scrape_pipeline.py's run_avito_city_sweep.
Test updates: 2 pre-existing tests (test_backfill_wave2.py) mocked the legacy
IMVAddressNotFoundError/IMVEvaluation/IMVGeo classes, now updated to import
from scraper_kit to match the production exception identity. Added
config=/delay_provider= regression-guard asserts to the relevant estimator
and backfill tests, mirroring the existing #2306 cian_price_history pattern.
Legacy app/services/scrapers/{avito_imv,cian_valuation,yandex_valuation}.py
are untouched and still imported by cian_history_backfill.py's valuation
block (separately scoped, not touched here) — revert is a clean single-commit
revert, no schema/data migration involved.
scraper_kit.providers.cian.valuation.estimate_via_cian_valuation (and
session.load_session) already existed with mandatory config: ScraperConfig
(no default) — this was ground prepared ahead of Group E2, not new work here.
This change proves it's safe to switch estimator.py's import to it (#2337):
- Extends the existing _parse_valuation_state golden-parity test with a
full-pipeline parity test (cache-miss -> load_session -> curl_cffi fetch ->
extract_state -> auth-check -> parse -> sanity-check), wired with
config=RealScraperConfig() per the #2306 convention
(app/services/cian_price_history.py).
- Adds a regression guard proving estimate_via_cian_valuation raises TypeError
when config= is omitted, and documents why that must stay mandatory:
estimator.py's call site (line ~3143) sits inside a blanket
`except Exception` that only does `logger.warning(..., exc)` (no exc_info,
no logger.exception) -- below GlitchTip's ERROR-level capture threshold
(see LoggingIntegration(event_level=logging.ERROR) in app/main.py), so a
TypeError from a missing config= would be logged but invisible in
GlitchTip/Sentry, indistinguishable from ordinary graceful degradation.
- Documents an asymmetry vs. the task's initial assumption: kit's
load_session requires config= mandatorily, but mark_session_invalid does
NOT take a config parameter at all (confirmed by inspection, not a
footgun -- just noting the actual contract).
- Confirms app/services/estimator.py is not the only real caller:
app/tasks/cian_history_backfill.py also imports estimate_via_cian_valuation
directly and deliberately stays on the legacy import for now (see its own
comment referencing issue #2308) -- out of scope for this change, flagged
for whoever picks up cian_history_backfill's migration.
Full backend test suite: 3211 passed, 6 skipped, 1 pre-existing unrelated
failure (test_search_api.py::test_search_cache_hit, 401 vs 200 auth issue,
reproduces identically on main without this change).
Group E3 recon confirmed `scraper_kit.providers.yandex.valuation` is already a
strangler-copy (#2133) and already used via admin.py's debug route (#2305,
Group A) with the established config=RealScraperConfig()/delay_provider=
get_scraper_delay convention. estimator.py (the real caller, #2337's scope)
still imports the legacy module directly with no arguments -- not touched here.
Adds regression coverage for the two confirmed footguns before #2337 switches
that call site:
- mandatory `config: ScraperConfig` raises TypeError when omitted (same
discipline as the cian_valuation footgun, #2335)
- optional `delay_provider` silently falls back to hardcoded 5.0s when not
wired, discarding admin-tuned anti-ban throttling (scraper_settings.
get_scraper_delay)
Also extends the existing `.parse()` golden-parity test (test_scraper_kit_
yandex_golden_parity.py, SimpleNamespace config) with an assert_parity-based
proof over the REAL production construction path (RealScraperConfig() +
get_scraper_delay), plus a harness-catches-divergence sanity check.
No production code changed -- app/services/scrapers/yandex_valuation.py and
scraper_kit/providers/yandex/valuation.py are both pre-existing.
Group E1 of the scraper_kit migration epic (#2277 -> #2308 -> #2334): prepares the
kit side of app/services/scrapers/avito_imv.py -> scraper_kit.providers.avito.imv
WITHOUT switching any real call site (estimator.py is #2337, a separate final step;
house_imv_backfill.py is deliberately deferred too, see report).
- Golden-parity: full evaluate_via_imv() async flow (geocode A/B + evaluate C) proven
byte-identical between legacy and kit on REAL live-captured Avito API responses
(tests/fixtures/avito_imv_geo_position.json + avito_imv_getdata.json), routed through
the browser_fetcher transport to stay offline/deterministic. Previously this function
had zero parity coverage (only _parse_price was covered, via admin.py Group A).
Added cheap pure-function parity for compute_imv_cache_key/_parse_geo_position/
_parse_placement_history/_parse_suggestions too.
- Config-footgun regression: kit evaluate_via_imv only reads config.scraper_proxy_url
when config= is explicitly passed; called bare it silently builds a proxy-less
curl_cffi session even with a real proxy configured. Two tests lock this down
(without config= -> proxies=None, with config=RealScraperConfig() -> proxies wired) —
manually verified by breaking scraper_kit/providers/avito/imv.py's config threading,
watching the "with config" test fail on the exact proxies assertion, then reverting.
No production import switched in this commit — see PR/issue report for the full
caller audit and the house_imv_backfill.py deferral rationale.
Code-review follow-up: the Group C regression tests for the fetch_detail
backconnect footgun and AvitoScraper's config requirement proved the KIT
LIBRARY behavior in isolation, but nothing asserted the actual call sites in
avito_detail_backfill.py still pass config=RealScraperConfig() at fetch_detail
and AvitoScraper construction. Mirrors #2306's test_backfill_wave2.py:282-286
pattern -- isinstance-checks the captured call_args instead of a bare
assert_called().
Verified the new assertions actually catch the regression: temporarily
removed both config=RealScraperConfig() call-site args from
avito_detail_backfill.py, confirmed test_backfill_processes_snapshot_to_completion
fails exactly on the new assertion line, then restored (git diff was empty
afterward).
Refs #2310
Migrates legacy app.services.scrapers.* imports to scraper_kit equivalents for
house_imv_backfill.py, avito_detail_backfill.py, cian_history_backfill.py,
ekb_geoportal_ingest.py, and yandex_detail_backfill.py, proving parity via
tests/support/parity.assert_parity per the epic's gate (#2304).
newbuilding_enrich_backfill.py and yandex_newbuilding_sweep.py are left fully
on legacy imports: both call into scraper_kit.providers.{cian,yandex}.newbuilding,
which construct BrowserFetcher(source=...) without the now-mandatory endpoint=
kwarg (issue #2322, verified still open against the actual provider source, not
just issue status) -- no caller-side fix is possible, matching Group A's (#2305)
precedent for the same bug in admin.py.
Config-gated kit function footguns found and fixed (Group B #2306 pattern):
- avito fetch_detail's backconnect-on-403 retry silently drops when config=
is omitted -- now passes config=RealScraperConfig() explicitly, with a
regression test proving the gate.
- kit AvitoScraper's constructor now requires ScraperConfig positionally --
wired via RealScraperConfig(), which _rotate_ip() reads for
avito_proxy_rotate_url.
- BrowserFetcher(source=...) call sites (house_imv_backfill, avito/cian
detail backfills) now pass the mandatory endpoint=settings.browser_http_endpoint.
New footgun discovered (NOT #2322, flagged for follow-up): kit's
build_warmed_session() builds its curl_cffi session via _build_detail_session()
with no config parameter at all, unlike fetch_detail -- migrating it would
silently drop the sticky MGTS-proxy egress on avito_detail_backfill's
warm-batch path (the prod default). Left build_warmed_session/
_AVITO_WARM_SEARCH_URL on legacy imports, documented inline.
Refs #2310