fix(tradein/scraper-kit): клампить diff_percent перед INSERT offer_price_history (#2404)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m28s
Deploy Trade-In / deploy (push) Successful in 1m45s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m38s
Deploy Trade-In / build-backend (push) Successful in 1m28s
Deploy Trade-In / deploy (push) Successful in 1m45s
This commit is contained in:
parent
ab250a132b
commit
2e345acb82
6 changed files with 210 additions and 3 deletions
|
|
@ -34,6 +34,24 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# offer_price_history.diff_percent — NUMERIC(8,2) (миграция
|
||||||
|
# 131_fix_diff_percent_overflow.sql). Прямой INSERT ниже читает diff_percent из
|
||||||
|
# скрапленного Cian JSON и не проходит через БД-триггер record_listing_price_change()
|
||||||
|
# (тот срабатывает только на UPDATE listings) — гарбадж/экстремальное значение
|
||||||
|
# с редизайна source-страницы иначе переполняет колонку.
|
||||||
|
_DIFF_PERCENT_BOUND = 999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_diff_percent(value: float | int | None) -> float | None:
|
||||||
|
"""Клампит diff_percent в границы NUMERIC(8,2) (зеркало migration 131 trigger-clamp)."""
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
numeric = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return min(max(numeric, -_DIFF_PERCENT_BOUND), _DIFF_PERCENT_BOUND)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DetailEnrichment:
|
class DetailEnrichment:
|
||||||
|
|
@ -390,7 +408,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
|
||||||
"lid": listing_id,
|
"lid": listing_id,
|
||||||
"ct": change["change_time"],
|
"ct": change["change_time"],
|
||||||
"price": change["price_rub"],
|
"price": change["price_rub"],
|
||||||
"diff": change.get("diff_percent"),
|
"diff": _clamp_diff_percent(change.get("diff_percent")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
|
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||||
from scraper_kit.providers.domclick.detail import (
|
from scraper_kit.providers.domclick.detail import (
|
||||||
DomClickDetailEnrichment,
|
DomClickDetailEnrichment,
|
||||||
_extract_ssr_state,
|
_extract_ssr_state,
|
||||||
|
|
@ -409,3 +410,94 @@ def test_save_detail_enrichment_none_passthrough() -> None:
|
||||||
assert params["living_area_m2"] is None
|
assert params["living_area_m2"] is None
|
||||||
# пустой raw_extra → "{}" (merge no-op, не затирает)
|
# пустой raw_extra → "{}" (merge no-op, не затирает)
|
||||||
assert params["raw_extra"] == "{}"
|
assert params["raw_extra"] == "{}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── save_detail_enrichment — diff_percent клампится перед INSERT (#2277) ──────
|
||||||
|
# offer_price_history.diff_percent — NUMERIC(8,2) (миграция 131). Прямой INSERT
|
||||||
|
# здесь не проходит через БД-триггер (тот срабатывает только на UPDATE listings) —
|
||||||
|
# garbage/экстремальный diff_percent из скрапленного HTML иначе переполняет колонку.
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_diff_param(db: MagicMock) -> float | None:
|
||||||
|
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
|
||||||
|
assert len(insert_calls) == 1
|
||||||
|
return insert_calls[0][0][1]["diff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_clamps_extreme_positive_diff() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="x",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 5000000}],
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, 1, e)
|
||||||
|
assert _insert_diff_param(db) == 999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="x",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}],
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, 1, e)
|
||||||
|
assert _insert_diff_param(db) == -999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_normal_diff_unchanged() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="x",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, 1, e)
|
||||||
|
assert _insert_diff_param(db) == -2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_none_diff_unchanged() -> None:
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.rowcount = 1
|
||||||
|
ct = datetime(2026, 4, 1, tzinfo=UTC)
|
||||||
|
e = DomClickDetailEnrichment(
|
||||||
|
item_id="x",
|
||||||
|
source_url=_CARD_URL,
|
||||||
|
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": None}],
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, 1, e)
|
||||||
|
assert _insert_diff_param(db) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── clamp_diff_percent (scraper_kit.offer_price_history) — standalone unit ────
|
||||||
|
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
|
||||||
|
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
|
||||||
|
# которые его же и используют.
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_diff_percent_extreme_positive() -> None:
|
||||||
|
assert clamp_diff_percent(5000000) == 999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_diff_percent_extreme_negative() -> None:
|
||||||
|
assert clamp_diff_percent(-5000000) == -999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_diff_percent_in_range_unchanged() -> None:
|
||||||
|
assert clamp_diff_percent(-2.5) == -2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_diff_percent_none() -> None:
|
||||||
|
assert clamp_diff_percent(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_diff_percent_bool_treated_as_none() -> None:
|
||||||
|
assert clamp_diff_percent(True) is None
|
||||||
|
assert clamp_diff_percent(False) is None
|
||||||
|
|
|
||||||
|
|
@ -608,6 +608,71 @@ def test_save_detail_enrichment_stamps_detail_enriched_at():
|
||||||
assert "detail_enriched_at = NOW()" in update_sql
|
assert "detail_enriched_at = NOW()" in update_sql
|
||||||
|
|
||||||
|
|
||||||
|
# ── save_detail_enrichment — diff_percent клампится перед INSERT (#2277) ──────
|
||||||
|
# offer_price_history.diff_percent — NUMERIC(8,2) (миграция 131). Этот прямой
|
||||||
|
# INSERT не проходит через БД-триггер record_listing_price_change() (тот
|
||||||
|
# срабатывает только на UPDATE listings) — garbage/экстремальный diff_percent
|
||||||
|
# из скрапленного Cian JSON иначе переполняет колонку.
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_diff_param(db: MagicMock) -> float | None:
|
||||||
|
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
|
||||||
|
assert len(insert_calls) == 1
|
||||||
|
return insert_calls[0][0][1]["diff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_clamps_extreme_positive_diff():
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.fetchone.return_value = None
|
||||||
|
enrichment = DetailEnrichment(
|
||||||
|
price_changes=[
|
||||||
|
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": 5000000}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, listing_id=2001, enrichment=enrichment)
|
||||||
|
assert _insert_diff_param(db) == 999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_clamps_extreme_negative_diff():
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.fetchone.return_value = None
|
||||||
|
enrichment = DetailEnrichment(
|
||||||
|
price_changes=[
|
||||||
|
{
|
||||||
|
"change_time": "2026-04-01T00:00:00Z",
|
||||||
|
"price_rub": 5000000,
|
||||||
|
"diff_percent": -5000000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, listing_id=2002, enrichment=enrichment)
|
||||||
|
assert _insert_diff_param(db) == -999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_normal_diff_unchanged():
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.fetchone.return_value = None
|
||||||
|
enrichment = DetailEnrichment(
|
||||||
|
price_changes=[
|
||||||
|
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": -2.5}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, listing_id=2003, enrichment=enrichment)
|
||||||
|
assert _insert_diff_param(db) == -2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_detail_enrichment_none_diff_unchanged():
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.fetchone.return_value = None
|
||||||
|
enrichment = DetailEnrichment(
|
||||||
|
price_changes=[
|
||||||
|
{"change_time": "2026-04-01T00:00:00Z", "price_rub": 5000000, "diff_percent": None}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
save_detail_enrichment(db, listing_id=2004, enrichment=enrichment)
|
||||||
|
assert _insert_diff_param(db) is None
|
||||||
|
|
||||||
|
|
||||||
# ── fetch_detail browser_fetcher param ───────────────────────────────────────
|
# ── fetch_detail browser_fetcher param ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Клампинг diff_percent перед прямым INSERT в offer_price_history.
|
||||||
|
|
||||||
|
offer_price_history.diff_percent — NUMERIC(8,2) (±999999.99, см. миграцию
|
||||||
|
131_fix_diff_percent_overflow.sql). Та миграция клампит diff_percent, который
|
||||||
|
СЧИТАЕТ БД-триггер record_listing_price_change() на UPDATE listings, но триггер
|
||||||
|
не срабатывает на прямые INSERT из provider-модулей (domclick/cian detail),
|
||||||
|
которые пишут diff_percent, взятый напрямую из скрапленного HTML/JSON внешнего
|
||||||
|
источника. Garbage/экстремальное значение с редизайна source-страницы иначе
|
||||||
|
переполняет колонку и спамит "numeric field overflow" в проде.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
_DIFF_PERCENT_BOUND = 999999.99
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_diff_percent(value: float | int | None) -> float | None:
|
||||||
|
"""Clamp raw scraped diff_percent into offer_price_history's NUMERIC(8,2) bound.
|
||||||
|
|
||||||
|
Mirrors the DB-trigger clamp in migration 131_fix_diff_percent_overflow.sql
|
||||||
|
(LEAST(GREATEST(x, -999999.99), 999999.99)) so direct application-level INSERTs
|
||||||
|
(bypassing that trigger) cannot overflow the column either.
|
||||||
|
"""
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
numeric = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return min(max(numeric, -_DIFF_PERCENT_BOUND), _DIFF_PERCENT_BOUND)
|
||||||
|
|
@ -21,6 +21,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
from scraper_kit.cian_state_parser import extract_all_states, extract_state
|
||||||
|
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||||
from scraper_kit.providers._base import build_curl_cffi_session
|
from scraper_kit.providers._base import build_curl_cffi_session
|
||||||
from scraper_kit.providers._proxy import curl_proxy_url
|
from scraper_kit.providers._proxy import curl_proxy_url
|
||||||
from scraper_kit.repair_state_normalizer import (
|
from scraper_kit.repair_state_normalizer import (
|
||||||
|
|
@ -400,7 +401,7 @@ def save_detail_enrichment(db: Session, listing_id: int, enrichment: DetailEnric
|
||||||
"lid": listing_id,
|
"lid": listing_id,
|
||||||
"ct": change["change_time"],
|
"ct": change["change_time"],
|
||||||
"price": change["price_rub"],
|
"price": change["price_rub"],
|
||||||
"diff": change.get("diff_percent"),
|
"diff": clamp_diff_percent(change.get("diff_percent")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
|
from scraper_kit.offer_price_history import clamp_diff_percent
|
||||||
from scraper_kit.repair_state_normalizer import (
|
from scraper_kit.repair_state_normalizer import (
|
||||||
infer_repair_state_from_text,
|
infer_repair_state_from_text,
|
||||||
normalize_repair_state,
|
normalize_repair_state,
|
||||||
|
|
@ -572,7 +573,7 @@ def save_detail_enrichment(
|
||||||
"lid": listing_id,
|
"lid": listing_id,
|
||||||
"ct": ct,
|
"ct": ct,
|
||||||
"price": price,
|
"price": price,
|
||||||
"diff": change.get("diff_percent"),
|
"diff": clamp_diff_percent(change.get("diff_percent")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue