240 lines
8.4 KiB
Python
240 lines
8.4 KiB
Python
"""Unit-тесты для DomClick scraper (#796).
|
||
|
||
Тестируем:
|
||
- Парсинг title-паттерна (rooms/area/floor) из текста карточки
|
||
- Парсинг цены из отдельного DOM-элемента
|
||
- Извлечение source_id из href
|
||
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.domclick import (
|
||
DomClickScraper,
|
||
_extract_area,
|
||
_extract_floor,
|
||
_extract_price_from_element,
|
||
_extract_rooms,
|
||
_extract_source_id,
|
||
)
|
||
|
||
# ── Rooms / studio ───────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text, expected",
|
||
[
|
||
("2-комн. квартира 53,5 м² 5/15 эт.", 2),
|
||
("1-к. квартира 32,1 м² 3/9 эт.", 1),
|
||
("3-комн. кв. 78 м² 10/12 эт.", 3),
|
||
("4-комн. квартира 95 м² 2/5 эт.", 4),
|
||
("студия 24 м² 1/10 эт.", 0),
|
||
("Студия 30,5 м² 8/22 эт.", 0),
|
||
("Нет комнат — просто текст", None),
|
||
],
|
||
)
|
||
def test_extract_rooms(text: str, expected: int | None) -> None:
|
||
assert _extract_rooms(text) == expected
|
||
|
||
|
||
# ── Area ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text, expected",
|
||
[
|
||
("2-комн. квартира 53,5 м² 5/15 эт.", 53.5),
|
||
("студия 24 м² 1/10 эт.", 24.0),
|
||
("78м2 на этаже", 78.0),
|
||
("площадь 101.7 м²", 101.7),
|
||
("Нет площади", None),
|
||
],
|
||
)
|
||
def test_extract_area(text: str, expected: float | None) -> None:
|
||
assert _extract_area(text) == expected
|
||
|
||
|
||
# ── Floor ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text, floor, total",
|
||
[
|
||
("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15),
|
||
("студия 24 м² 1/10 эт.", 1, 10),
|
||
("12/24 эт.", 12, 24),
|
||
("нет этажа", None, None),
|
||
],
|
||
)
|
||
def test_extract_floor(text: str, floor: int | None, total: int | None) -> None:
|
||
assert _extract_floor(text) == (floor, total)
|
||
|
||
|
||
# ── Price (per-element) ───────────────────────────────────────────────────────
|
||
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
|
||
# поэтому числа адреса не загрязняют парсинг цены.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text, expected",
|
||
[
|
||
("5 200 000 ₽", 5_200_000),
|
||
("12 345 678 ₽", 12_345_678),
|
||
("3 990 000 руб.", 3_990_000),
|
||
("от 4 500 000 ₽", 4_500_000),
|
||
("без цены", None),
|
||
("0 ₽", None),
|
||
],
|
||
)
|
||
def test_extract_price_from_element(text: str, expected: int | None) -> None:
|
||
assert _extract_price_from_element(text) == expected
|
||
|
||
|
||
# ── source_id from href ───────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"href, expected",
|
||
[
|
||
("/card/sale__flat__2075671636", "2075671636"),
|
||
("https://domclick.ru/card/sale__flat__123456789", "123456789"),
|
||
("/card/sale__flat__1", "1"),
|
||
("/card/something_else", None),
|
||
("", None),
|
||
],
|
||
)
|
||
def test_extract_source_id(href: str, expected: str | None) -> None:
|
||
assert _extract_source_id(href) == expected
|
||
|
||
|
||
# ── _parse_html -> list[ScrapedLot] ──────────────────────────────────────────
|
||
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля,
|
||
# чтобы не сливаться с адресным номером дома при конкатенации card_text.
|
||
|
||
_MOCK_HTML = """\
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<!-- Карточка 1: комнатность, площадь, этаж, адрес, цена в отдельных span -->
|
||
<a href="/card/sale__flat__2075671636">
|
||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
||
<span>ул. Малышева, 1</span>
|
||
<span>5 200 000 ₽</span>
|
||
</a>
|
||
|
||
<!-- Карточка 2: студия -->
|
||
<a href="/card/sale__flat__1111111111">
|
||
<span>Студия 28 м² 3/10 эт.</span>
|
||
<span>пр-т Ленина, 50</span>
|
||
<span>3 100 000 ₽</span>
|
||
</a>
|
||
|
||
<!-- Карточка 3: без цены -> должна быть отфильтрована -->
|
||
<a href="/card/sale__flat__9999999999">
|
||
<span>1-комн. квартира 32 м² 2/9 эт.</span>
|
||
</a>
|
||
|
||
<!-- Не карточка (без sale__flat__) -> должна быть отфильтрована -->
|
||
<a href="/card/sale__apartment__123">
|
||
<span>что-то другое</span>
|
||
</a>
|
||
|
||
<!-- Дубликат первой карточки -> один раз на страницу -->
|
||
<a href="/card/sale__flat__2075671636">
|
||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
||
<span>5 200 000 ₽</span>
|
||
</a>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def test_parse_html_returns_lots() -> None:
|
||
scraper = DomClickScraper()
|
||
lots = scraper._parse_html(_MOCK_HTML)
|
||
|
||
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
|
||
assert len(lots) == 2
|
||
|
||
|
||
def test_parse_html_card1_fields() -> None:
|
||
scraper = DomClickScraper()
|
||
lots = scraper._parse_html(_MOCK_HTML)
|
||
|
||
card1 = next(lot for lot in lots if lot.source_id == "2075671636")
|
||
assert card1.source == "domclick"
|
||
assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636"
|
||
assert card1.rooms == 2
|
||
assert card1.area_m2 == 53.5
|
||
assert card1.floor == 5
|
||
assert card1.total_floors == 15
|
||
assert card1.price_rub == 5_200_000
|
||
assert card1.lat is None
|
||
assert card1.lon is None
|
||
assert card1.listing_segment == "vtorichka"
|
||
|
||
|
||
def test_parse_html_studio() -> None:
|
||
scraper = DomClickScraper()
|
||
lots = scraper._parse_html(_MOCK_HTML)
|
||
|
||
studio = next(lot for lot in lots if lot.source_id == "1111111111")
|
||
assert studio.rooms == 0 # студия
|
||
assert studio.area_m2 == 28.0
|
||
assert studio.price_rub == 3_100_000
|
||
|
||
|
||
def test_parse_html_no_price_filtered() -> None:
|
||
scraper = DomClickScraper()
|
||
lots = scraper._parse_html(_MOCK_HTML)
|
||
|
||
source_ids = {lot.source_id for lot in lots}
|
||
assert "9999999999" not in source_ids
|
||
|
||
|
||
def test_parse_html_dedup_same_href() -> None:
|
||
"""Дубликат href на одной странице должен давать одну карточку."""
|
||
scraper = DomClickScraper()
|
||
lots = scraper._parse_html(_MOCK_HTML)
|
||
|
||
ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"]
|
||
assert len(ids_2075) == 1
|
||
|
||
|
||
def test_scraper_source_name() -> None:
|
||
scraper = DomClickScraper()
|
||
assert scraper.name == "domclick"
|
||
assert scraper.source == "domclick"
|
||
assert scraper.base_url == "https://domclick.ru"
|
||
assert scraper.request_delay_sec == 8.0
|
||
|
||
|
||
def test_fetch_around_raises() -> None:
|
||
scraper = DomClickScraper()
|
||
with pytest.raises(NotImplementedError, match="geo-radius"):
|
||
asyncio.run(scraper.fetch_around(56.8, 60.6))
|
||
|
||
|
||
def test_build_url_no_rooms() -> None:
|
||
scraper = DomClickScraper()
|
||
url = scraper._build_url(city_id=4, rooms=None, page=1)
|
||
assert "city_id=4" in url
|
||
assert "rooms" not in url
|
||
assert "p=1" in url
|
||
|
||
|
||
def test_build_url_with_rooms() -> None:
|
||
scraper = DomClickScraper()
|
||
url = scraper._build_url(city_id=4, rooms=2, page=3)
|
||
assert "rooms=2" in url
|
||
assert "p=3" in url
|
||
|
||
|
||
def test_parse_failures_counter() -> None:
|
||
"""parse_failures счётчик начинается с 0."""
|
||
scraper = DomClickScraper()
|
||
assert scraper.parse_failures == 0
|