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:
parent
b6ea986134
commit
f2cfe66b02
2 changed files with 216 additions and 0 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
201
tradein-mvp/backend/tests/test_yandex_history_area_filter.py
Normal file
201
tradein-mvp/backend/tests/test_yandex_history_area_filter.py
Normal 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}
|
||||
Loading…
Add table
Reference in a new issue