All checks were successful
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) 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 / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
- Migration 111: ceiling_height_m numeric → numeric(5,2) (schema consistency with living_area_m2/kitchen_area_m2) - test_finishing_stored_lowercase_stripped: fixture now uses padded uppercase ' Чистовая ' to actually exercise .strip().lower(), asserts finishing == 'чистовая'
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""Smoke тесты для avito_detail parser.
|
||
|
||
Загружает minimal HTML fixture и проверяет selector + conversion logic
|
||
без сетевых запросов.
|
||
"""
|
||
|
||
# ruff: noqa: E501 # HTML-фикстуры содержат длинные строки разметки
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.avito_detail import (
|
||
DetailEnrichment,
|
||
_parse_height_m,
|
||
parse_detail_html,
|
||
)
|
||
|
||
MINIMAL_HTML = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)</div>
|
||
<span itemprop="price" content="11990000">11 990 000 ₽</span>
|
||
<div data-marker="item-map-wrapper" data-map-lat="56.797713" data-map-lon="60.609135" data-location-id="654070"></div>
|
||
<div data-marker="item-view/item-params">
|
||
<ul>
|
||
<li>Количество комнат: 3</li>
|
||
<li>Общая площадь: 75 м²</li>
|
||
<li>Площадь кухни: 15 м²</li>
|
||
<li>Этаж: 20 из 26</li>
|
||
<li>Балкон или лоджия: лоджия</li>
|
||
<li>Ремонт: евро</li>
|
||
<li>Способ продажи: свободная</li>
|
||
<li>Условия продажи: возможна ипотека</li>
|
||
</ul>
|
||
<ul>
|
||
<li>Тип дома: монолитный</li>
|
||
<li>Этажей в доме: 26</li>
|
||
</ul>
|
||
</div>
|
||
<a data-marker="nd-jk-details-button" href="/catalog/houses/ekaterinburg/ul_postovskogo_17a/3171365">Дом</a>
|
||
<div data-marker="domoteka-entry-block">
|
||
<p data-marker="TeaserData.item">1 собственник</p>
|
||
<p data-marker="TeaserData.item">Последняя смена собственника 5 мая 2020</p>
|
||
<p data-marker="TeaserData.item">Не найдены ограничения и обременения</p>
|
||
<p data-marker="TeaserData.item">Совпадают площадь, адрес и этаж</p>
|
||
</div>
|
||
<div data-marker="item-view/item-description">Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.</div>
|
||
</body></html>
|
||
"""
|
||
|
||
SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804"
|
||
|
||
|
||
def test_minimal_parse() -> None:
|
||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||
assert isinstance(result, DetailEnrichment)
|
||
assert result.item_id == "7986882804"
|
||
assert result.price_rub == 11990000
|
||
assert result.rooms == 3
|
||
assert result.area_m2 == 75.0
|
||
assert result.kitchen_area_m2 == 15.0
|
||
assert result.floor == 20
|
||
assert result.total_floors == 26
|
||
assert result.balcony_loggia == "loggia"
|
||
assert result.repair_state == "good" # «евро» → good (normalized enum, #621)
|
||
assert result.sale_type == "free"
|
||
assert result.mortgage_available is True
|
||
assert result.house_type == "monolith"
|
||
assert result.total_floors_house == 26
|
||
assert result.lat == 56.797713
|
||
assert result.lon == 60.609135
|
||
assert result.avito_location_id == 654070
|
||
assert result.house_catalog_url is not None and "3171365" in result.house_catalog_url
|
||
assert result.owners_count == 1
|
||
assert result.owners_at_least is False
|
||
assert result.last_owner_change_date is not None and result.last_owner_change_date.year == 2020
|
||
assert result.encumbrances_clean is True
|
||
assert result.registry_match is True
|
||
assert result.views_total == 1899
|
||
assert result.views_today == 12
|
||
|
||
|
||
def test_metro_extraction() -> None:
|
||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||
# "Чкаловская 11-15 мин пешком" → metro_stations
|
||
assert len(result.metro_stations) >= 1
|
||
station = result.metro_stations[0]
|
||
assert station["name"] == "Чкаловская"
|
||
assert station["min_from"] == 11
|
||
assert station["min_to"] == 15
|
||
assert station["mode"] == "walk"
|
||
|
||
|
||
def test_owners_at_least_false() -> None:
|
||
"""Один собственник (без 'или больше') → owners_at_least=False."""
|
||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||
assert result.owners_count == 1
|
||
assert result.owners_at_least is False
|
||
|
||
|
||
def test_owners_at_least_true() -> None:
|
||
"""'3 собственника или больше' → owners_at_least=True."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 1234567890</div>
|
||
<span itemprop="price" content="5000000">5 000 000 ₽</span>
|
||
<div data-marker="domoteka-entry-block">
|
||
<p data-marker="TeaserData.item">3 собственника или больше</p>
|
||
</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_1234567890")
|
||
assert result.owners_count == 3
|
||
assert result.owners_at_least is True
|
||
|
||
|
||
def test_missing_domoteka_all_none() -> None:
|
||
"""Если нет domoteka-блока — все 5 полей None."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 9999999999</div>
|
||
<span itemprop="price" content="3000000">3 000 000 ₽</span>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_9999999999")
|
||
assert result.owners_count is None
|
||
assert result.owners_at_least is None
|
||
assert result.last_owner_change_date is None
|
||
assert result.encumbrances_clean is None
|
||
assert result.registry_match is None
|
||
|
||
|
||
def test_invalid_html_raises_value_error() -> None:
|
||
"""HTML без item_id → ValueError."""
|
||
html = "<html><body><p>Нет айди</p></body></html>"
|
||
with pytest.raises(ValueError, match="Cannot extract item_id"):
|
||
parse_detail_html(html, "https://www.avito.ru/test")
|
||
|
||
|
||
def test_house_catalog_url_full() -> None:
|
||
"""house_catalog_url должен быть absolute URL."""
|
||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||
assert result.house_catalog_url is not None
|
||
assert result.house_catalog_url.startswith("https://www.avito.ru")
|
||
|
||
|
||
def test_house_catalog_url_secondary_marker_no_nd_jk() -> None:
|
||
"""#871: вторичка без nd-jk-details-button → fallback на /catalog/houses/ якорь;
|
||
?context-трекинг отброшен (verified live: house-link есть в detail SSR)."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 7683141312</div>
|
||
<span itemprop="price" content="4000000">4 000 000 ₽</span>
|
||
<a href="/catalog/houses/ekaterinburg/ul_gotvalda_6k1/142792?context=H4sIabc">
|
||
Узнать больше о доме</a>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_7683141312")
|
||
assert (
|
||
result.house_catalog_url
|
||
== "https://www.avito.ru/catalog/houses/ekaterinburg/ul_gotvalda_6k1/142792"
|
||
)
|
||
|
||
|
||
def test_bathroom_type_combined() -> None:
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 1111111111</div>
|
||
<span itemprop="price" content="4000000">4 000 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul>
|
||
<li>Санузел: совмещенный</li>
|
||
<li>Окна: во двор и на улицу</li>
|
||
<li>Тип комнат: изолированные</li>
|
||
</ul>
|
||
</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_1111111111")
|
||
assert result.bathroom_type == "combined"
|
||
assert result.windows_view == "both"
|
||
assert result.room_layout == "isolated"
|
||
|
||
|
||
def test_photo_urls_extracted() -> None:
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 2222222222</div>
|
||
<span itemprop="price" content="7000000">7 000 000 ₽</span>
|
||
<div data-marker="image-preview/item"><img src="https://img.avito.ru/img1.jpg"/></div>
|
||
<div data-marker="image-preview/item"><img src="https://img.avito.ru/img2.jpg"/></div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_2222222222")
|
||
assert len(result.photo_urls) == 2
|
||
assert result.photo_urls[0] == "https://img.avito.ru/img1.jpg"
|
||
|
||
|
||
def test_source_url_preserved() -> None:
|
||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||
assert result.source_url == SOURCE_URL
|
||
|
||
|
||
# ── repair_state fallback из описания (#622) ──────────────────────────────────
|
||
|
||
|
||
def test_repair_state_structured_wins_over_description() -> None:
|
||
"""Структурное «Ремонт: евро» (good) в приоритете даже при сигнале в тексте."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 3333333333</div>
|
||
<span itemprop="price" content="6000000">6 000 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul><li>Ремонт: евро</li></ul>
|
||
</div>
|
||
<div data-marker="item-view/item-description">Квартира без отделки, под чистовую</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_3333333333")
|
||
assert result.repair_state == "good" # структурное поле, не needs_repair из текста
|
||
|
||
|
||
def test_repair_state_inferred_from_description_when_structured_absent() -> None:
|
||
"""Нет блока «Ремонт» → инференс из описания (#622)."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 4444444444</div>
|
||
<span itemprop="price" content="8000000">8 000 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul><li>Количество комнат: 2</li></ul>
|
||
</div>
|
||
<div data-marker="item-view/item-description">Сделан свежий евроремонт, заезжай и живи</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_4444444444")
|
||
assert result.repair_state == "good"
|
||
|
||
|
||
def test_repair_state_none_when_no_signal() -> None:
|
||
"""Нет структурного поля и нет сигнала в тексте → None (не фабрикуем)."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 5555555555</div>
|
||
<span itemprop="price" content="9000000">9 000 000 ₽</span>
|
||
<div data-marker="item-view/item-description">Светлая квартира рядом с метро</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_5555555555")
|
||
assert result.repair_state is None
|
||
|
||
|
||
# ── Новые поля: living_area_m2, ceiling_height_m, finishing ──────────────────
|
||
|
||
|
||
_NEW_FIELDS_HTML = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 6666666666</div>
|
||
<span itemprop="price" content="8500000">8 500 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul>
|
||
<li>Количество комнат: 1</li>
|
||
<li>Общая площадь: 43.7\xa0м²</li>
|
||
<li>Площадь кухни: 17.1\xa0м²</li>
|
||
<li>Жилая площадь: 12.6\xa0м²</li>
|
||
<li>Высота потолков: 2.7\xa0м</li>
|
||
<li>Отделка: чистовая</li>
|
||
<li>Этаж: 6 из 16</li>
|
||
</ul>
|
||
</div>
|
||
</body></html>
|
||
"""
|
||
|
||
|
||
def test_new_apartment_fields_populated() -> None:
|
||
"""living_area_m2 / ceiling_height_m / finishing парсятся из params-блока."""
|
||
result = parse_detail_html(_NEW_FIELDS_HTML, "https://www.avito.ru/test_6666666666")
|
||
assert result.living_area_m2 == 12.6
|
||
assert result.ceiling_height_m == 2.7
|
||
assert result.finishing == "чистовая"
|
||
|
||
|
||
def test_new_fields_absent_when_not_in_params() -> None:
|
||
"""Если полей нет в params — None, не дефолт."""
|
||
result = parse_detail_html(
|
||
"""
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 7777777777</div>
|
||
<span itemprop="price" content="4000000">4 000 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul><li>Количество комнат: 2</li></ul>
|
||
</div>
|
||
</body></html>
|
||
""",
|
||
"https://www.avito.ru/test_7777777777",
|
||
)
|
||
assert result.living_area_m2 is None
|
||
assert result.ceiling_height_m is None
|
||
assert result.finishing is None
|
||
|
||
|
||
def test_finishing_stored_lowercase_stripped() -> None:
|
||
"""finishing → lowercase trimmed (' Чистовая ' → 'чистовая')."""
|
||
html = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 8888888888</div>
|
||
<span itemprop="price" content="5000000">5 000 000 ₽</span>
|
||
<div data-marker="item-view/item-params">
|
||
<ul><li>Отделка: Чистовая </li></ul>
|
||
</div>
|
||
</body></html>
|
||
"""
|
||
result = parse_detail_html(html, "https://www.avito.ru/test_8888888888")
|
||
assert result.finishing == "чистовая"
|
||
|
||
|
||
# ── _parse_height_m unit tests ────────────────────────────────────────────────
|
||
|
||
|
||
def test_parse_height_m_dot() -> None:
|
||
assert _parse_height_m("2.7\xa0м") == 2.7
|
||
|
||
|
||
def test_parse_height_m_comma() -> None:
|
||
assert _parse_height_m("2,7\xa0м") == 2.7
|
||
|
||
|
||
def test_parse_height_m_integer() -> None:
|
||
assert _parse_height_m("3 м") == 3.0
|
||
|
||
|
||
def test_parse_height_m_junk() -> None:
|
||
assert _parse_height_m("нет данных") is None
|
||
|
||
|
||
def test_parse_height_m_empty() -> None:
|
||
assert _parse_height_m("") is None
|