"""`secondary_only` — параметр расписания, а не хардкод; выброшенное считается (#1781). `run_cian_full_load` передавал `secondary_only=True` жёстко (`pipeline.py:3328`), поэтому включить новостройки в полный обход можно было только деплоем. При этом новостройки НЕ пропускаются при запросе — они скачиваются, разбираются и выбрасываются последним шагом (`cian/serp.py:671`), потому что SERP-параметр `object_type=1` у Cian ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment` уже после парсинга. Следствие, проверенное по коду: проба бакета берёт `totalOffers` из Redux-состояния SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`; `totalOffers` включает обе категории. То есть страницы с новостройками уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены — включение стоит НОЛЬ дополнительных запросов. Дефолт НЕ меняется: `True`, поведение прода прежнее. Меняется только то, что решение становится правкой одной ячейки `scrape_schedules.default_params`, а не деплоем. Замер прода 21.08.2026, ради которого это и делается: месячный охват свипа cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import inspect def test_full_load_no_longer_hardcodes_secondary_only() -> None: """Головной: значение берётся из параметра, а не из литерала. На origin/main в теле стоит `secondary_only=True` — включить новостройки можно только правкой кода. """ from scraper_kit.orchestration import pipeline src = inspect.getsource(pipeline.run_cian_full_load) assert "secondary_only=secondary_only" in src, "значение не пробрасывается из параметра" assert "secondary_only=True," not in src, ( "в теле остался хардкод — расписание на него повлиять не сможет" ) def test_default_is_unchanged() -> None: """Контроль: дефолт остаётся True — поведение прода не меняется этим PR. Без этого контроля правка «сделать параметром» могла бы тихо включить сбор новостроек на проде, а это отдельное решение с замером (#1781, #2994). """ from scraper_kit.orchestration.pipeline import run_cian_full_load params = inspect.signature(run_cian_full_load).parameters assert "secondary_only" in params, ( "параметра нет в сигнатуре — значение задано в теле, расписание на него " f"повлиять не может; параметры: {list(params)}" ) default = params["secondary_only"].default assert default is True, f"дефолт изменён на {default!r} — это не входило в правку" def test_admin_request_exposes_the_flag_with_same_default() -> None: """Параметр доезжает до расписания: у эндпоинта есть поле с тем же дефолтом.""" from app.api.v1.admin import CianFullLoadRequest поля = CianFullLoadRequest.model_fields assert "secondary_only" in поля, ( "поля нет в запросе — параметр не доедет из scrape_schedules.default_params; " f"поля: {sorted(поля)}" ) assert поля["secondary_only"].default is True req = CianFullLoadRequest() assert req.secondary_only is True assert CianFullLoadRequest(secondary_only=False).secondary_only is False def test_counters_persist_dropped_novostroyki() -> None: """Выброшенное обязано сохраняться, а не только логироваться. Счётчик `dropped_nb` печатался в лог, но в `scrape_runs.counters` не попадал, и ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем: логи за нужную дату уже ротировались. Тот же довод, по которому рядом заведён `partial_buckets`. """ from scraper_kit.orchestration.pipeline import CianFullLoadCounters c = CianFullLoadCounters() assert "dropped_novostroyki" in c.to_dict(), c.to_dict() c.dropped_novostroyki = 7 assert c.to_dict()["dropped_novostroyki"] == 7 def test_scraper_resets_the_counter_per_run() -> None: """Контроль: счётчик сбрасывается на каждый прогон. Инстанс скрапера переиспользуется; без сброса второй прогон унаследовал бы число первого и записал бы в counters завышенное значение. """ from scraper_kit.providers.cian.serp import CianScraper src = inspect.getsource(CianScraper.fetch_all_secondary) assert "self.last_dropped_nb = 0" in src, "нет сброса в начале прогона" assert "self.last_dropped_nb += " in inspect.getsource(CianScraper._paginate_leaf_bucket), ( "накопление не там, где считается dropped_nb" ) assert getattr(CianScraper, "last_dropped_nb", None) == 0, ( "нет класс-дефолта: атрибут не прочитается, если прогон упал до первого бакета" ) def test_filter_still_drops_when_flag_is_on() -> None: """Контроль от переусердствования: при secondary_only=True фильтр остаётся. Правка делает флаг управляемым, а не отменяет его. """ from scraper_kit.providers.cian.serp import CianScraper # Фильтр лежит в _paginate_leaf_bucket — том методе, что собирает страницы # бакета; fetch_all_secondary только раздаёт флаг вниз. src = inspect.getsource(CianScraper._paginate_leaf_bucket) assert 'lot.listing_segment != "novostroyki"' in src, "фильтр пропал" assert "if secondary_only:" in src, "фильтр перестал зависеть от флага"