gendesign/tradein-mvp/backend/tests/test_1781_secondary_only_param.py
bot-backend b9cfdaa040
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
feat(tradein): secondary_only — параметр расписания, выброшенное считается (#1781)
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому
включить новостройки в полный обход можно было только деплоем. Теперь это
параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на
строку, но решение становится правкой одной ячейки
`scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же
движением.

Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе:
они скачиваются, разбираются и выбрасываются последним шагом
(`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian
ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment`
после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP
и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а
`totalOffers` включает ОБЕ категории — то есть страницы с новостройками
уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены.
Включение стоит ноль дополнительных запросов.

Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик
логировался (`dropped_nb=`), но не персистился, и ответить «сколько
инвентаря выбрасывает полный обход» задним числом было нечем: логи за
17.08 уже ротировались — `docker logs --since 120h` не находит ни строки
«cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён
`partial_buckets`. Копится в атрибуте инстанса, а не аргументом
`on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру
ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется.

Замер, ради которого это делается (прод 21.08): месячный охват свипа
cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и
avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки,
10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994.

Двусторонне: против origin/main пять тестов красные, и краснота везде по
значению, а не по отсутствию символа — ни одного KeyError. Сообщения
перечисляют фактическое состояние («параметра нет в сигнатуре; параметры:
[...]», «поля нет в запросе; поля: [...]»).

Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо
включила бы сбор новостроек на проде — это отдельное решение с замером);
фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит
от флага.

pytest tradein-mvp/backend — 4644 passed, 23 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:08:11 +05:00

123 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""`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, "фильтр перестал зависеть от флага"