All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
123 lines
7.1 KiB
Python
123 lines
7.1 KiB
Python
"""`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, "фильтр перестал зависеть от флага"
|