fix(scrapers): yandex history skip area<=0 items

_save_yandex_history_items теперь пропускает items с area_m2 is None или
area_m2 <= 0 (битый парс «0,5 м²» и пр.) перед сохранением в
house_placement_history. Грязь копилась в БД и искажала price_trend.

Estimator защищён NULLIF на уровне SQL, но фильтрация на входе надёжнее.
Лог кол-ва отброшенных (info). Батч не падает из-за одного битого item.
This commit is contained in:
bot-backend 2026-06-21 00:25:43 +03:00
parent b6ea986134
commit f2cfe66b02
2 changed files with 216 additions and 0 deletions

View file

@ -772,7 +772,14 @@ def _save_yandex_history_items(
)
rows = []
skipped_area = 0
for item in result.history_items:
# Фильтруем items с нулевой/отрицательной/отсутствующей площадью — битый парс
# («0,5 м²» и пр.) сохраняет мусор в house_placement_history и искажает
# price_trend. Estimator защищён NULLIF, но грязь копится → чистим на входе.
if item.area_m2 is None or item.area_m2 <= 0:
skipped_area += 1
continue
ext_seed = (
f"{result.address}|{item.publish_date}|{item.area_m2}|{item.floor}|"
f"{item.start_price}|{item.last_price}"
@ -797,6 +804,14 @@ def _save_yandex_history_items(
}
)
if skipped_area > 0:
logger.info(
"yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)",
skipped_area,
len(result.history_items),
result.address,
)
sql = text(
"""
INSERT INTO house_placement_history (

View file

@ -0,0 +1,201 @@
"""Tests for Fix 3: yandex history items с area_m2 <= 0 или None отброшены при сохранении.
_save_yandex_history_items должен:
- пропустить item если area_m2 is None или area_m2 <= 0
- сохранить валидные items (area_m2 > 0) без потерь
- не ронять батч из-за одного битого item
- лого кол-во отброшенных items
"""
from __future__ import annotations
import os
from datetime import date
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.estimator import _save_yandex_history_items
from app.services.scrapers.yandex_valuation import (
ValuationHistoryItem,
ValuationHouseMeta,
YandexValuationResult,
)
def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult:
return YandexValuationResult(
address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106",
offer_category="APARTMENT",
offer_type="SELL",
page=1,
source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test",
house=ValuationHouseMeta(year_built=2010, total_floors=16),
history_items=items,
)
def _valid_item(area_m2: float = 50.0) -> ValuationHistoryItem:
return ValuationHistoryItem(
area_m2=area_m2,
rooms=2,
floor=5,
start_price=9_000_000,
last_price=8_800_000,
publish_date=date(2024, 4, 10),
exposure_days=30,
status="Снято",
)
def _item_with_area(area_m2: float | None) -> ValuationHistoryItem:
return ValuationHistoryItem(
area_m2=area_m2,
rooms=1,
floor=2,
start_price=6_000_000,
last_price=6_000_000,
publish_date=date(2024, 1, 1),
exposure_days=10,
)
# ── тест 1: item с area=None отброшен ────────────────────────────────────────
def test_item_with_area_none_is_skipped() -> None:
"""Item с area_m2=None должен быть пропущен, валидный item сохранён."""
items = [
_item_with_area(None), # битый — пропускаем
_valid_item(50.0), # валидный — сохраняем
]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(99, 0.9, "fp"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}"
rows = db.execute.call_args_list[0].args[1]
assert len(rows) == 1
assert rows[0]["area"] == 50.0
# ── тест 2: item с area=0 отброшен ───────────────────────────────────────────
def test_item_with_area_zero_is_skipped() -> None:
"""Item с area_m2=0 должен быть пропущен."""
items = [
_item_with_area(0.0), # area=0 — пропускаем
_item_with_area(0), # area=0 (int) — пропускаем
_valid_item(55.0), # валидный
]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(1, 1.0, "new"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 1
rows = db.execute.call_args_list[0].args[1]
assert rows[0]["area"] == 55.0
# ── тест 3: item с area < 0 отброшен ─────────────────────────────────────────
def test_item_with_negative_area_is_skipped() -> None:
"""Item с area_m2 < 0 должен быть пропущен."""
items = [
_item_with_area(-5.0), # отрицательная площадь
_valid_item(42.0),
]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(2, 0.7, "geo"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 1
# ── тест 4: все битые → 0 сохранено, но батч не падает ───────────────────────
def test_all_invalid_area_returns_zero_no_crash() -> None:
"""Все items с area=None или 0 → saved=0, ни одного db.execute INSERT-вызова с данными."""
items = [
_item_with_area(None),
_item_with_area(0.0),
_item_with_area(None),
]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(3, 0.9, "fp"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 0
# db.execute не должен вызываться для пустого rows (нет INSERT)
# Commit вызывается, rollback — нет.
db.commit.assert_called_once()
db.rollback.assert_not_called()
# ── тест 5: все валидные — всё сохранено ─────────────────────────────────────
def test_all_valid_items_all_saved() -> None:
"""Все items с корректной площадью сохраняются без потерь."""
items = [_valid_item(float(40 + i * 5)) for i in range(5)]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(10, 0.9, "fp"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 5
# ── тест 6: микс валидных и битых ─────────────────────────────────────────────
def test_mixed_items_only_valid_saved() -> None:
"""Микс: 2 валидных, 3 битых → saved=2."""
items = [
_valid_item(40.0),
_item_with_area(None),
_valid_item(60.0),
_item_with_area(0.0),
_item_with_area(-1.0),
]
db = MagicMock()
result = _make_result(items)
with patch(
"app.services.estimator.match_or_create_house",
return_value=(20, 0.9, "fp"),
):
saved = _save_yandex_history_items(db, result)
assert saved == 2
rows = db.execute.call_args_list[0].args[1]
assert len(rows) == 2
areas = {r["area"] for r in rows}
assert areas == {40.0, 60.0}