gendesign/tradein-mvp/backend/tests/test_avito_detail_parse.py
Light1YT 04550bc94f
All checks were successful
CI / changes (pull_request) Successful in 5s
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
fix(tradein): drop non-EKB avito detail coords on ingest (audit #1871 P2)
avito_detail.parse_detail_html писал data-map-lat/lon прямо в listings
UPDATE COALESCE без bbox-валидации — в отличие от geocoder.py, который
фильтрует везде. Итог: 406 active avito-листингов с координатами
Питер/Тюмень/Уфа просочились в ЕКБ-датасет (ложные аналоги через Tier A
address-match оценщика). Prod-verified 2026-06-23.

- geocoder: DRY-хелпер is_within_ekb_bbox(lat, lon, bbox) + именованные
  EKB_BBOX_TIGHT (geocoder-фильтр, числа не изменены) и EKB_BBOX_WIDE
  (ingest-guard); 2 inline tight-проверки → хелпер (поведение identical).
- avito_detail: после извлечения lat/lon → reset None + logger.warning
  если вне wide-bbox; листинг падает в geocode-cron путь (COALESCE NULL
  не затирает прежнее хорошее значение).
- tests: bbox-хелпер (ЕКБ True, Питер/Тюмень/Уфа False, inclusive,
  WIDE⊇TIGHT инвариант) + parse_detail_html guard end-to-end.

WIDE строго содержит TIGHT — guard физически не режет ничего, что
geocoder сам бы пропустил. NULL lat (14935, geocode backlog) — отдельная
проблема, не в scope. One-time cleanup существующих 406 — отдельно
(database-expert, prod data mutation).

Refs #1871
2026-06-23 14:33:10 +05:00

400 lines
16 KiB
Python
Raw 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.

"""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_sale_type_assignment_pereustupka() -> None:
"""«Способ продажи: переуступка» → sale_type='assignment' (ДДУ переуступка,
сигнал новостройки). До фикса map не содержал ключ → None."""
html = """
<html><body>
<div data-marker="item-view/item-id">№ 8043003287</div>
<span itemprop="price" content="9500000">9 500 000 ₽</span>
<div data-marker="item-view/item-params">
<ul>
<li>Количество комнат: 2</li>
<li>Способ продажи: переуступка</li>
</ul>
</div>
</body></html>
"""
result = parse_detail_html(html, "https://www.avito.ru/test_8043003287")
assert result.sale_type == "assignment"
def _html_with_coords(item_id: str, lat: str, lon: str) -> str:
return f"""
<html><body>
<div data-marker="item-view/item-id">№ {item_id}</div>
<span itemprop="price" content="9500000">9 500 000 ₽</span>
<div data-marker="item-map-wrapper" data-map-lat="{lat}" data-map-lon="{lon}" data-location-id="654070"></div>
</body></html>
"""
def test_coord_guard_keeps_ekb_coords() -> None:
"""ЕКБ-координаты (внутри wide-bbox) пишутся как есть → попадут в UPDATE (#1871)."""
html = _html_with_coords("8043000001", "56.797713", "60.609135")
result = parse_detail_html(html, "https://www.avito.ru/test_8043000001")
assert result.lat == 56.797713
assert result.lon == 60.609135
assert result.avito_location_id == 654070
def test_coord_guard_drops_piter_coords() -> None:
"""Питерские координаты (вне wide-bbox) → lat/lon сбрасываются в None (#1871).
Это значение уходит в save_detail_enrichment UPDATE COALESCE(:lat, lat) → NULL,
то есть существующее значение в БД не перетирается мусором, листинг уходит
в geocode-cron путь.
"""
html = _html_with_coords("8043000002", "59.9386", "30.3141") # СПб
result = parse_detail_html(html, "https://www.avito.ru/test_8043000002")
assert result.lat is None
assert result.lon is None
# avito_location_id НЕ трогаем — фикс только на координаты
assert result.avito_location_id == 654070
def test_coord_guard_no_map_element() -> None:
"""Без map-элемента координат нет — guard не падает, lat/lon=None."""
html = """
<html><body>
<div data-marker="item-view/item-id">№ 8043000003</div>
<span itemprop="price" content="9500000">9 500 000 ₽</span>
</body></html>
"""
result = parse_detail_html(html, "https://www.avito.ru/test_8043000003")
assert result.lat is None
assert result.lon is None
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