All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (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 1m43s
app.services.scrape_pipeline (3734 строк) имел 0 runtime-импортёров — Part C убрал
последнего вызывающего (admin.py, scheduler_main.py уже на
scraper_kit.orchestration.pipeline). Удаление:
- backend/app/services/scrape_pipeline.py — удалён целиком
Тесты (18 файлов, импортировавших scrape_pipeline):
DELETE (чистая legacy-оркестрация, kit-эквивалент покрыт в
test_scraper_kit_pipeline_parity{,2}.py):
- test_scrape_pipeline.py, test_scrape_pipeline_proxy_pool.py (proxy-pool DB-lease
вытеснен RealProxyProvider DI-адаптером, покрытым test_scraper_adapters_contracts.py)
- test_anchor_watchdog.py, test_ban_rotation_1790.py, test_provider_rotation_1848.py
- test_scraper_resilience_1949_1950.py, test_sweep_drain.py
CONVERT (parity-сравнение убрано, kit-only regression оставлен/усилен):
- test_scraper_kit_pipeline_parity.py, test_scraper_kit_pipeline_parity2.py —
_drive_old убран, _drive_new стал единственной веткой; ассёрты на конкретные
значения counters сохранены (это теперь основное regression-покрытие kit
orchestration)
- test_sweep_imv_phase.py — 6 IMV-phase тестов (enrich_imv true/false/no-touched/
crash/failed-counter/cancel) переведены на run_avito_city_sweep (kit) с DI
(config/matcher/enrichment); enrichment.process_houses_imv_batch — инжектируемый
Protocol-метод вместо прямого импорта app.services.house_imv_backfill
PARTIAL-EDIT (легаси sweep-behavior вырезан, admin/kit-DI/несвязанные тесты
оставлены):
- test_city_sweep.py — только retarget 4 импортов на scraper_kit.orchestration.pipeline
- test_cian_city_sweep.py, test_yandex_city_sweep.py — sweep-behavior секции удалены,
admin endpoint тесты (уже kit-DI) + anchor-timeout/watchdog формулы (retarget import)
оставлены
- test_domclick_sweep.py — sweep-wiring тесты удалены, Counters + _map_item оставлены
- test_avito_newbuilding_sweep.py — sweep-wiring тест #5 удалён, retarget
NewbuildingSweepCounters
- test_pipeline_browser_routing.py — 3 run_avito_pipeline теста удалены, browser-routing
на уровне fetch_detail/fetch_house_catalog/AvitoScraper не тронут
- test_scraper_proxy.py, tests/scrapers/test_avito_anti_bot.py — _avito_proxies()
переведён на kit (DI-параметр вместо settings-patch); pipeline-level тесты удалены
Stale-комментарии (scrape_pipeline.py как текущая реальность) поправлены в
admin.py, avito_detail_backfill.py, house_imv_backfill.py, yandex_price_history.py,
scrapers/avito_detail.py + kit-пакете (orchestration/__init__.py, orchestration
/pipeline.py, providers/avito/detail.py — strangler завершён, легаси удалён).
grep -rn scrape_pipeline app/ scripts/ packages/ — только past-tense упоминания
("удалён в Part E1", "перед его удалением") + один historical footnote
(contracts.py:138, provenance-заметка "собраны grep'ом по ... .py").
Backend suite: 3088 passed, 6 skipped (известный flake test_search_cache_hit #2208 —
не регрессия). ruff check чист на всех изменённых файлах.
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""Avito newbuilding (novostroyka) citywide sweep.
|
||
|
||
Тесты (без сети, без реального DB):
|
||
1. _build_newbuilding_url — novostroyka-slug в пути, s=104, page param, без geo.
|
||
2. _parse_html на novostroyka-SERP fixture → listing_segment='novostroyki' +
|
||
newbuilding_id заполнен (DOM-маркер застройщика + zhk-якорь).
|
||
3. fetch_newbuildings break-on-empty + dedup (mirror fetch_city_wide).
|
||
4. NewbuildingSweepCounters — defaults + to_dict.
|
||
|
||
Legacy `run_avito_newbuilding_sweep` orchestration wiring (fetch_newbuildings +
|
||
save_listings + mark_done) удалён вместе с `app.services.scrape_pipeline` (#2397
|
||
Part E1) — эквивалентная regression-coverage теперь в
|
||
`test_scraper_kit_pipeline_parity2.py::test_avito_newbuilding_sweep` (kit
|
||
`run_avito_newbuilding_sweep`).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.scrapers.avito import NOVOSTROYKA_SLUG, AvitoScraper
|
||
from app.services.scrapers.base import ScrapedLot
|
||
|
||
FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_novostroyka.html"
|
||
|
||
|
||
def _make_lot(source_id: str) -> ScrapedLot:
|
||
return ScrapedLot(
|
||
source="avito",
|
||
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
|
||
source_id=source_id,
|
||
price_rub=6_000_000,
|
||
listing_segment="novostroyki",
|
||
)
|
||
|
||
|
||
# ── 1. _build_newbuilding_url ───────────────────────────────────────────────
|
||
|
||
|
||
def test_build_newbuilding_url_contains_slug_and_sort() -> None:
|
||
s = AvitoScraper()
|
||
url = s._build_newbuilding_url(page=1)
|
||
assert NOVOSTROYKA_SLUG in url
|
||
assert "novostroyka-" in url
|
||
assert "geoCoords" not in url
|
||
assert "radius" not in url
|
||
assert "s=104" in url
|
||
assert "p=1" in url
|
||
assert "ekaterinburg/kvartiry/prodam/" in url
|
||
|
||
|
||
def test_build_newbuilding_url_page_param() -> None:
|
||
s = AvitoScraper()
|
||
url = s._build_newbuilding_url(page=2)
|
||
assert "novostroyka-" in url
|
||
assert "p=2" in url
|
||
|
||
|
||
def test_build_newbuilding_url_differs_from_citywide() -> None:
|
||
s = AvitoScraper()
|
||
assert s._build_newbuilding_url(page=1) != s._build_citywide_url(page=1)
|
||
|
||
|
||
# ── 2. _parse_html classifies novostroyka cards ─────────────────────────────
|
||
|
||
|
||
def test_parse_novostroyka_fixture_segments_and_newbuilding_id() -> None:
|
||
html = FIXTURE.read_text(encoding="utf-8")
|
||
s = AvitoScraper()
|
||
lots = s._parse_html(html, source_url_base=s._build_newbuilding_url(page=1))
|
||
|
||
assert len(lots) == 2
|
||
for lot in lots:
|
||
assert lot.listing_segment == "novostroyki"
|
||
assert lot.newbuilding_id is not None
|
||
assert lot.newbuilding_url is not None
|
||
|
||
ids = {lot.newbuilding_id for lot in lots}
|
||
assert "meridian-ekaterinburg" in ids
|
||
assert "severnyy-kvartal-ekaterinburg" in ids
|
||
|
||
|
||
# ── 3. fetch_newbuildings break-on-empty + dedup ────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_newbuildings_break_on_empty() -> None:
|
||
s = AvitoScraper()
|
||
pages_data: list[list[ScrapedLot]] = [[_make_lot("A"), _make_lot("B")], []]
|
||
call_n = 0
|
||
|
||
async def mock_html(url: str, page: int) -> str:
|
||
assert "novostroyka-" in url
|
||
return f"<html>{page}</html>"
|
||
|
||
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||
nonlocal call_n
|
||
idx = call_n
|
||
call_n += 1
|
||
return pages_data[idx] if idx < len(pages_data) else []
|
||
|
||
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
|
||
with patch.object(s, "_parse_html", side_effect=mock_parse):
|
||
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
|
||
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
|
||
|
||
assert len(result) == 2
|
||
assert call_n == 2 # page1 + page2(empty) → stop
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetch_newbuildings_dedup_by_source_id() -> None:
|
||
s = AvitoScraper()
|
||
dup = _make_lot("SAME")
|
||
pages_data: list[list[ScrapedLot]] = [[dup], [dup], []]
|
||
call_n = 0
|
||
|
||
async def mock_html(url: str, page: int) -> str:
|
||
return f"<html>{page}</html>"
|
||
|
||
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
|
||
nonlocal call_n
|
||
idx = call_n
|
||
call_n += 1
|
||
return pages_data[idx] if idx < len(pages_data) else []
|
||
|
||
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
|
||
with patch.object(s, "_parse_html", side_effect=mock_parse):
|
||
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
|
||
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
|
||
|
||
assert len(result) == 1
|
||
assert result[0].source_id == "SAME"
|
||
|
||
|
||
def test_fetch_city_wide_url_unchanged() -> None:
|
||
"""fetch_city_wide остаётся citywide-URL — рефактор не сменил его поведение."""
|
||
s = AvitoScraper()
|
||
assert "novostroyka-" not in s._build_citywide_url(page=1)
|
||
assert "prodam-ASgBAgICAUSSA8YQ" in s._build_citywide_url(page=1)
|
||
|
||
|
||
# ── 4. NewbuildingSweepCounters ─────────────────────────────────────────────
|
||
|
||
|
||
def test_newbuilding_counters_defaults_and_to_dict() -> None:
|
||
from scraper_kit.orchestration.pipeline import NewbuildingSweepCounters
|
||
|
||
c = NewbuildingSweepCounters()
|
||
assert c.lots_fetched == 0
|
||
assert c.lots_inserted == 0
|
||
assert c.lots_updated == 0
|
||
assert c.errors_count == 0
|
||
d = c.to_dict()
|
||
assert set(d.keys()) == {"lots_fetched", "lots_inserted", "lots_updated", "errors_count"}
|