gendesign/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py
bot-backend ff979d4afd
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) 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 Trade-In / browser-tests (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 5m11s
fix(tradein/cian): капча Циана с HTTP 200 — отказ площадки в обоих слоях (#3402)
Циан отдаёт капчу (`<title>Captcha - база объявлений ЦИАН`, 44 КБ) и страницу
ошибки (`<title>Ошибка - Циан`, 374 КБ) с кодом 200. Детектор сайдкара их не знал
(_REFUSAL_STATUSES {403,429} + маркеры Авито/Домклика), HTML уезжал клиенту как
успех, extract_state возвращал None и провайдер печатал «defaultState extraction
failed» — отказ ПЛОЩАДКИ читался как дрейф НАШЕЙ разметки. Аренда при этом не
менялась: fetch() уже отрапортовал mark_health(ok=True), fail-streak обнулялся, и
один капча-узел сжигал батч целиком (6200: 0/210; 6123/6091/6052/6032/6010/5981:
0/400 — против 161/162 через здоровый узел на прогоне 13).

Два слоя, потому что образы backend и browser деплоятся раздельно и расходятся
на часы:
* сайдкар (browser/server.py) — детект по <title> на обоих путях (navigate и
  подзапрос) → BanPageDetectedError → прежний путь #3288/#3379: 403 + ban_page +
  ЧЕСТНЫЙ upstream-статус 200;
* kit (providers/cian/detail.py) — при провале extract_state те же маркеры →
  CianBlockedError вместо тихого None, плюс report_platform_ban по живому lease.
  Там же ветка SidecarBanPageError: отказ, опознанный сайдкаром, больше не
  гасится общим `except` в «не смогли разобрать».

Слово `captcha` признаком быть не может: в нормальной карточке оно встречается 11
раз, на капче 17. Детект по <title> с нормализацией тире.

`_report_platform_ban` → `report_platform_ban` (публичный): тем же путём обязан
идти отказ, распознанный не сайдкаром, а провайдером. report_ban один только
пишет бан пары «узел×источник» — сменить сожжённую аренду ВНУТРИ батча позволяет
только fail-streak (_LEASE_ROTATE_AFTER_FAILS).
2026-09-06 14:10:57 +05:00

1026 lines
50 KiB
Python
Raw Permalink 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.

"""Unit-тесты для DomClick Layer B detail-scraper (scraper_kit.providers.domclick.detail).
Offline: parse/SSR-тесты без сети/БД; save-тест через MagicMock (зеркало
test_cian_detail.py — в проекте нет live-DB фикстуры, идемпотентность
гарантируется ON CONFLICT-клаузой, которая проверяется на уровне SQL-строки).
Покрытие:
- _extract_ssr_state: bare `undefined` → null; in-string "undefined" выживает;
скобки/эскейпы внутри строк не ломают balanced-scan; missing marker →
DomClickParseError; challenge → DomClickBlockedError; unbalanced/невалидный
JSON → DomClickParseError.
- parse_detail_html (REAL __SSR_STATE__ paths, live card 2075729321):
productCard.objectInfo.{renovation→repair_state+repair_type,livingArea,kitchenArea};
balconies absent→None; productCard.legalOptions.saleType; egrnData snake_case
owners_count + encumbrances инверсия (collateral); houseInfo.info.{wallType,floorType}
→ raw_extra (НЕ house_type); productCard.viewsCount; priceInfo.priceHistory
(ISO8601+offset kept tz-aware / bad-date + no-price skipped); top-level
pricePrediction → raw_extra.avm (плоский + nested .result); raw_extra без None;
item_id из URL.
- fetch_detail: browser-branch + проброс ошибок.
- save_detail_enrichment: UPDATE-параметры, detail_enriched_at, price-history INSERT.
Легаси `app.services.scrapers.domclick_detail` удалён (#2277 финальный шаг
scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент
`scraper_kit.providers.domclick.detail` без изменения покрытия. Exceptions тоже
переведены на `scraper_kit.domclick_exceptions` (kit держит свою независимую копию,
`parse_detail_html`(kit) поднимает именно её). Legacy `app.services.scrapers.
domclick`/`domclick_exceptions` удалены целиком (#2397 Part E2, 0 runtime
importers) — `scraper_kit.domclick_exceptions` остаётся единственным живым классом
для `pytest.raises`.
"""
from __future__ import annotations
import itertools
import json
import logging
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.offer_price_history import (
drop_decimal_slips,
recompute_diff_percent,
validate_diff_percent,
)
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
_extract_ssr_state,
canon_sale_type,
fetch_detail,
parse_detail_html,
save_detail_enrichment,
)
_CARD_URL = "https://ekaterinburg.domclick.ru/card/sale__flat__2075729321"
# Литерал с bare `undefined` (value-позиция), in-string "undefined", скобкой
# внутри строки и эскейп-кавычками — написан вручную, json.dumps использовать
# нельзя (нужен именно НЕвалидный-для-json undefined).
# Real __SSR_STATE__ shape (ground truth, live card 2075729321, 2026-06-27).
# bare `undefined` (value-pos), in-string "undefined", '{' внутри строки и
# эскейп-кавычки оставлены для проверки санитайзера/balanced-scan; json.dumps
# использовать нельзя (нужен именно НЕвалидный-для-json undefined).
# priceHistory[0].date намеренно flush-left (длинная ISO+offset строка) — отступ
# в литерале не влияет на парсер, но держит физическую строку ≤100 для ruff.
_SSR_LITERAL = """{
"productCard": {
"objectInfo": {
"area": 38.0,
"rooms": 1,
"floor": 5,
"isApartment": false,
"renovation": "евроремонт",
"livingArea": 22.37,
"kitchenArea": 10.13,
"description": "Состояние пока undefined.",
"note": "угол {da}",
"quote": "сказал \\"привет\\" сосед"
},
"legalOptions": {"saleType": "Свободная продажа"},
"egrnData": {
"area": 38.2,
"floor": 5,
"owners_count": 1,
"collateral": true,
"collateral_sber": false
},
"priceInfo": {
"priceHistory": [
{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"},
{"date": "2026-03-01T09:00:00+03:00", "price": 13800000, "diff": -200000, "state": "less"},
{"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"},
{"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"}
]
},
"viewsCount": 338,
"callsCount": 5,
"favoriteOfferUsersCount": 12,
"duplicatesOfferCount": 0,
"address": {"guid": "abc-guid-123"},
"unknownField": undefined
},
"houseInfo": {
"info": {
"buildYear": 2015,
"wallType": "Кирпично-монолитный",
"floorType": "Железобетонный",
"entranceCount": 3,
"quartersCount": 120,
"energyEfficiency": "B",
"buildingSeries": "индивидуальный"
}
},
"pricePrediction": {
"market_price": 13000000,
"min_market_price": 12000000,
"max_market_price": 14000000,
"rent_short": 50000,
"rent_long": 35000,
"repair_quality": "good"
}
}"""
_HTML = (
"<html><head></head><body><script>"
f"window.__SSR_STATE__ = {_SSR_LITERAL};"
"</script></body></html>"
)
def _wrap(state: dict) -> str:
"""Заворачивает валидный dict в SSR-HTML (для variant-фикстур без undefined)."""
literal = json.dumps(state, ensure_ascii=False)
return f"<html><body><script>window.__SSR_STATE__ = {literal};</script></body></html>"
# ── _extract_ssr_state ────────────────────────────────────────────────────────
def test_extract_ssr_state_parses_despite_undefined() -> None:
state = _extract_ssr_state(_HTML)
# bare undefined в value-позиции → null → None
assert state["productCard"]["unknownField"] is None
# in-string "undefined" НЕ испорчен санитайзером
assert state["productCard"]["objectInfo"]["description"] == "Состояние пока undefined."
def test_extract_ssr_state_brace_and_escape_in_string() -> None:
state = _extract_ssr_state(_HTML)
oi = state["productCard"]["objectInfo"]
# '{' внутри строки не ломает balanced-brace scan
assert oi["note"] == "угол {da}"
# эскейп-кавычки внутри строки обработаны
assert oi["quote"] == 'сказал "привет" сосед'
def test_extract_ssr_state_missing_marker_raises_parse() -> None:
with pytest.raises(DomClickParseError):
_extract_ssr_state("<html><body>no ssr state here</body></html>")
def test_extract_ssr_state_challenge_raises_blocked() -> None:
html = "<html><body>Access denied by DataDome captcha</body></html>"
with pytest.raises(DomClickBlockedError):
_extract_ssr_state(html)
# ── QRATOR markers (#2636) — Layer B had only 4/7 canonical markers, so a
# QRATOR block page using bot_mitigation/система защиты/403 | домклик fell
# through to DomClickParseError instead of DomClickBlockedError (block-breaker
# never triggered, batch burned through instead of pausing). Case varied per
# marker to exercise the case-insensitive comparison.
@pytest.mark.parametrize(
"html",
[
"<html><body>BOT_MITIGATION в процессе, подождите</body></html>",
"<html><body>СИСТЕМА ЗАЩИТЫ от ботов активна</body></html>",
"<html><body>403 | ДОМКЛИК — доступ ограничен</body></html>",
],
ids=["bot_mitigation_upper", "sistema_zashchity_upper", "403_domklik_upper"],
)
def test_extract_ssr_state_qrator_markers_raise_blocked_not_parse(html: str) -> None:
with pytest.raises(DomClickBlockedError):
_extract_ssr_state(html)
def test_extract_ssr_state_unbalanced_raises_parse() -> None:
with pytest.raises(DomClickParseError):
_extract_ssr_state('window.__SSR_STATE__ = {"a": 1')
def test_extract_ssr_state_invalid_json_raises_parse() -> None:
with pytest.raises(DomClickParseError):
_extract_ssr_state("window.__SSR_STATE__ = {this: is, not: json}")
# ── parse_detail_html — полная карточка ───────────────────────────────────────
def test_parse_detail_html_full() -> None:
e = parse_detail_html(_HTML, _CARD_URL)
assert e.item_id == "2075729321"
assert e.source_url == _CARD_URL
assert e.repair_state == "good" # евроремонт → good (LOCAL map)
assert e.repair_type == "евроремонт" # сырое значение сохранено
assert e.living_area_m2 == 22.37
assert e.kitchen_area_m2 == 10.13
# На реальной карточке нет ключа balconies в objectInfo → optional/None.
assert e.balconies_count is None
assert e.has_balcony is None
assert e.sale_type == "Свободная продажа"
assert e.owners_count == 1
assert e.encumbrances_clean is False # collateral=true → есть обременение
assert e.year_built == 2015
assert e.views_total == 338
def test_parse_detail_html_price_changes() -> None:
e = parse_detail_html(_HTML, _CARD_URL)
# 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time.
assert len(e.price_changes) == 2
first, second = e.price_changes
assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000]
# Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется
# (#3225): 13 400 000 / 13 800 000 1 = 2.9%, а НЕ 400000.
assert first["diff_percent"] is None # самая ранняя запись: предыдущей цены нет
assert second["diff_percent"] == -2.9
assert isinstance(second["change_time"], datetime)
# ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC.
assert second["change_time"].tzinfo is not None
assert second["change_time"].utcoffset() == timedelta(hours=3)
def test_parse_detail_html_price_changes_single_entry_has_no_percent() -> None:
html = _wrap(
{
"productCard": {
"priceInfo": {
"priceHistory": [
{"date": "2026-04-15T09:00:00+03:00", "price": 9000000, "diff": -300000}
]
}
}
}
)
e = parse_detail_html(html, _CARD_URL)
assert [c["diff_percent"] for c in e.price_changes] == [None]
def test_parse_detail_html_raw_extra() -> None:
e = parse_detail_html(_HTML, _CARD_URL)
raw = e.raw_extra
assert raw["wall_type"] == "Кирпично-монолитный"
assert raw["floor_type"] == "Железобетонный"
# wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type.
assert "house_type" not in raw
assert raw["domclick_building_guid"] == "abc-guid-123"
assert raw["egrn_area"] == 38.2
assert raw["demand"]["calls"] == 5
assert raw["demand"]["favorites"] == 12
# AVM (Layer C, top-level pricePrediction) → raw_extra.avm
assert raw["avm"]["market_price"] == 13000000
assert raw["avm"]["min"] == 12000000
assert raw["avm"]["max"] == 14000000
assert raw["avm"]["repair_quality"] == "good"
# нет ключа balconies → raw_extra НЕ содержит balconies_raw
assert "balconies_raw" not in raw
# никаких None-значений на верхнем уровне (компактность)
assert all(v is not None for v in raw.values())
# ── parse_detail_html — частные ветки ─────────────────────────────────────────
def test_parse_balconies_string_goes_to_raw_extra() -> None:
html = _wrap({"productCard": {"objectInfo": {"balconies": "есть"}}})
e = parse_detail_html(html, _CARD_URL)
assert e.balconies_count is None
assert e.has_balcony is None
assert e.raw_extra["balconies_raw"] == "есть"
def test_parse_encumbrances_clean_true_when_no_collateral() -> None:
html = _wrap({"productCard": {"egrnData": {"collateral": False, "collateral_sber": False}}})
e = parse_detail_html(html, _CARD_URL)
assert e.encumbrances_clean is True
def test_parse_encumbrances_none_when_egrn_missing() -> None:
html = _wrap({"productCard": {"objectInfo": {"livingArea": 30}}})
e = parse_detail_html(html, _CARD_URL)
assert e.encumbrances_clean is None
def test_parse_repair_map_hit() -> None:
html = _wrap({"productCard": {"objectInfo": {"renovation": "Косметический"}}})
e = parse_detail_html(html, _CARD_URL)
assert e.repair_state == "standard"
assert e.repair_type == "Косметический"
def test_parse_repair_fallback_to_inference() -> None:
# Не точное совпадение в карте → инференс из текста (евроремонт → good)
html = _wrap({"productCard": {"objectInfo": {"renovation": "сделан свежий евроремонт"}}})
e = parse_detail_html(html, _CARD_URL)
assert e.repair_state == "good"
assert e.repair_type == "сделан свежий евроремонт"
def test_parse_owners_count_camelcase_key() -> None:
html = _wrap({"productCard": {"egrnData": {"ownersCount": 3}}})
e = parse_detail_html(html, _CARD_URL)
assert e.owners_count == 3
def test_parse_avm_nested_under_result() -> None:
# pricePrediction может приходить обёрнутым в .result — unwrap должен сработать.
html = _wrap(
{
"productCard": {},
"pricePrediction": {"result": {"marketPrice": 9000000, "repairQuality": "fine"}},
}
)
e = parse_detail_html(html, _CARD_URL)
assert e.raw_extra["avm"]["market_price"] == 9000000
assert e.raw_extra["avm"]["repair_quality"] == "fine"
def test_parse_avm_absent_no_key() -> None:
# Нет pricePrediction → raw_extra без ключа avm (компактность).
e = parse_detail_html(_wrap({"productCard": {}}), _CARD_URL)
assert "avm" not in e.raw_extra
def test_parse_item_id_none_for_bad_url() -> None:
e = parse_detail_html(_wrap({"productCard": {}}), "https://domclick.ru/card/rent__room__1")
assert e.item_id is None
def test_dataclass_defaults() -> None:
e = DomClickDetailEnrichment()
assert e.item_id is None
assert e.price_changes == []
assert e.raw_extra == {}
# ── fetch_detail (browser-branch) ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_detail_parses_via_browser() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(return_value=_HTML)
e = await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert e.item_id == "2075729321"
assert e.repair_state == "good"
bf.fetch.assert_called_once_with(
_CARD_URL,
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=None,
)
@pytest.mark.asyncio
async def test_fetch_detail_passes_derived_vtorichka_origin() -> None:
"""fetch_detail выводит origin из scheme+netloc card_url → vtorichka-SERP root.
Организм-навигация (SERP до карточки) снижает подозрительность холодного
захода для QRATOR — см. комментарий в fetch_detail. Проверяем сам вывод origin
для другого хоста/пути, не завязываясь на _CARD_URL из happy-path теста выше.
"""
bf = MagicMock()
bf.fetch = AsyncMock(return_value=_HTML)
other_card_url = "https://spb.domclick.ru/card/sale__flat__999888777"
await fetch_detail(other_card_url, browser_fetcher=bf)
bf.fetch.assert_called_once_with(
other_card_url,
origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=None,
)
@pytest.mark.asyncio
async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None:
"""fetch_detail(cookies=...) пробрасывает cookies в browser_fetcher.fetch без изменений.
cookies — сессия из app.services.domclick_session.load_session (см. admin debug-роут
/scrape/domclick/debug/detail-fetch) — этот модуль сам её не загружает (kit не
импортирует app.*), только пробрасывает то, что передал caller.
"""
bf = MagicMock()
bf.fetch = AsyncMock(return_value=_HTML)
session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"}
await fetch_detail(_CARD_URL, browser_fetcher=bf, cookies=session_cookies)
bf.fetch.assert_called_once_with(
_CARD_URL,
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
cookies=session_cookies,
)
@pytest.mark.asyncio
async def test_fetch_detail_browser_failure_raises_blocked() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway"))
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
@pytest.mark.asyncio
async def test_fetch_detail_propagates_parse_error() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(return_value="<html><body>no state</body></html>")
with pytest.raises(DomClickParseError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
@pytest.mark.asyncio
async def test_fetch_detail_propagates_blocked_from_html() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(return_value="<html><body>Access denied datadome</body></html>")
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
# ── fetch_detail: report_ban на ГЕНУИННЫЙ маркер-детект (#2600 п.1) ────────────
#
# Различие: QRATOR-маркеры в HTML (parse_detail_html) — надёжно распознанный бан,
# report_ban ДОЛЖЕН вызываться. Голая ошибка транспорта (bf.fetch кинул) — это
# сетевой/инфраструктурный сбой, НЕ подтверждённый бан-маркер, report_ban НЕ
# вызывается (issue #2600 п.4 — не смешивать «бан» и «сетевой сбой»).
@pytest.mark.asyncio
async def test_fetch_detail_reports_ban_on_marker_detected_block() -> None:
bf = MagicMock()
bf.fetch = AsyncMock(return_value="<html><body>Access denied datadome</body></html>")
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
@pytest.mark.asyncio
async def test_fetch_detail_transport_failure_does_not_report_ban() -> None:
"""bf.fetch() кинул (502/timeout/network) — DomClickBlockedError поднимается как
обёртка (см. except Exception ветку fetch_detail), но report_ban НЕ вызывается —
это не подтверждённый маркер-бан, а транспортная ошибка."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway"))
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_not_called()
# ── #3239: бан-страница ОТ САЙДКАРА — тоже генуинный маркер-детект ────────────
#
# #3237 перенёс распознавание статического отказа Домклика из parse_detail_html в
# сайдкар (иначе незавершённое QRATOR-рукопожатие считалось блоком). Отказ стал
# приезжать обычной 500-кой и попадать в транспортную ветку, где report_ban по
# построению НЕ зовётся → ban_kind 'unknown' вместо 'platform' → ротация IP не
# запускалась вовсе. Тесты ниже держат обе ветки РАЗДЕЛЬНО: подтверждённый отказ
# банит, голый транспортный сбой — нет (тест выше).
def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPageError:
return SidecarBanPageError(
"Server error '500' | tradein-browser: BanPageDetectedError: статический отказ",
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
response=httpx.Response(500, request=httpx.Request("POST", "http://x/fetch")),
upstream_status=upstream_status,
)
@pytest.mark.asyncio
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
"""Sidecar-бан рапортует сам фетчер (`report_platform_ban`, #3288) — здесь уже нет.
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
свежий узел. Ветка `parse_detail_html` ниже — другой случай: тот детект наш,
фетчер его не видит, и там report_ban остаётся (см. тест следом за этим блоком).
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_not_called()
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_carries_upstream_status() -> None:
"""Статус берётся ИЗ ИСКЛЮЧЕНИЯ, а не из last_response_status.
Несущая деталь #3239: на error-пути fetch() обнуляет last_response_status,
поэтому у Домклика (отказ приходит с 401) классификатор без этого поля
поставил бы 'unknown'. MagicMock отдаёт last_response_status как Mock —
если бы код читал его, ассерт ниже упал бы.
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(401))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status == 401
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_without_status_stays_none() -> None:
"""Сайдкар не отдал статус → None, а не выдуманный код (#2764)."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error(None))
with pytest.raises(DomClickBlockedError) as excinfo:
await fetch_detail(_CARD_URL, browser_fetcher=bf)
assert excinfo.value.status is None
# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ─────────────
def test_save_detail_enrichment_writes_columns() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
repair_state="good",
repair_type="Евроремонт",
living_area_m2=45.5,
kitchen_area_m2=12.3,
balconies_count=2,
has_balcony=True,
sale_type="Свободная продажа",
owners_count=1,
encumbrances_clean=False,
year_built=2015,
views_total=1234,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
raw_extra={"wall_type": "монолит"},
)
ok = save_detail_enrichment(db, 999, e)
assert ok is True
# UPDATE — первый execute
update_sql = str(db.execute.call_args_list[0][0][0])
update_params = db.execute.call_args_list[0][0][1]
assert "detail_enriched_at = NOW()" in update_sql
assert update_params["lid"] == 999
assert update_params["repair_state"] == "good"
assert update_params["living_area_m2"] == 45.5
assert update_params["balconies_count"] == 2
assert update_params["has_balcony"] is True
assert update_params["encumbrances_clean"] is False
# #2674: в колонку уезжает общий с cian/avito токен, а не русская фраза.
assert update_params["sale_type"] == "free"
assert "монолит" in update_params["raw_extra"]
# price-history INSERT с ON CONFLICT (идемпотентность)
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
assert "ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq" in str(
insert_calls[0][0][0]
)
ins_params = insert_calls[0][0][1]
assert ins_params["price"] == 5000000
assert ins_params["ct"] == ct
db.commit.assert_called_once()
def test_save_detail_enrichment_leaves_recorded_at_to_default() -> None:
"""Строка загрузчика обязана иметь change_time <> recorded_at.
По этому признаку миграция 285 отличает строки загрузчика от строк триггера
record_listing_price_change (тот пишет обе метки одним now(), то есть равными) и
пересчитывает diff_percent только у первых. У триггера ДРУГАЯ база отсчёта —
listings.OLD.price_rub, — поэтому пересчёт его строк ломает честные значения.
Признак держится на двух вещах: change_time = дата источника (глубоко в прошлом),
а recorded_at загрузчик не указывает вовсе → DEFAULT NOW() на вставке.
Пропишут recorded_at в этот INSERT — признак сломается и тест покраснеет.
"""
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
)
assert save_detail_enrichment(db, 999, e) is True
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
insert_sql = str(insert_calls[0][0][0])
assert "recorded_at" not in insert_sql
# change_time — историческая дата источника, а не момент вставки.
assert insert_calls[0][0][1]["ct"] == ct
assert ct < datetime.now(UTC)
def test_save_detail_enrichment_listing_not_found() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 0
e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, repair_state="good")
assert save_detail_enrichment(db, 123, e) is False
def test_save_detail_enrichment_none_passthrough() -> None:
db = MagicMock()
db.execute.return_value.rowcount = 1
e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL) # все поля None
save_detail_enrichment(db, 1, e)
params = db.execute.call_args_list[0][0][1]
assert params["repair_state"] is None
assert params["living_area_m2"] is None
# пустой raw_extra → "{}" (merge no-op, не затирает)
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_rejects_rubles_as_diff(caplog: pytest.LogCaptureFixture) -> None:
"""Величина не того рода (рубли) → NULL + warning, НЕ 999999.99 (#3225)."""
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": 250000}],
)
with caplog.at_level(logging.WARNING):
save_detail_enrichment(db, 777, e)
assert _insert_diff_param(db) is None
assert any("250000" in r.getMessage() and "777" in r.getMessage() for r in caplog.records)
def test_save_detail_enrichment_rejects_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) is None
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
# ── validate_diff_percent (scraper_kit.offer_price_history) — standalone unit ─
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
# которые его же и используют.
def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
assert validate_diff_percent(250000, listing_id=406163) is None
assert any("250000" in r.getMessage() and "406163" in r.getMessage() for r in caplog.records)
def test_validate_diff_percent_rejects_extreme_negative() -> None:
assert validate_diff_percent(-5000000) is None
def test_validate_diff_percent_boundary_kept() -> None:
assert validate_diff_percent(100) == 100.0
assert validate_diff_percent(-100) == -100.0
assert validate_diff_percent(100.01) is None
def test_validate_diff_percent_in_range_unchanged() -> None:
assert validate_diff_percent(-2.5) == -2.5
assert validate_diff_percent(-0.82) == -0.82
def test_validate_diff_percent_none() -> None:
assert validate_diff_percent(None) is None
def test_validate_diff_percent_bool_treated_as_none() -> None:
assert validate_diff_percent(True) is None
assert validate_diff_percent(False) is None
# ── drop_decimal_slips (#3376) — сдвиг разряда в priceHistory источника ───────
# Прод-замер 06.09.2026 под ЭТИМ критерием: 38 точек (cian 12, domklik 26,
# yandex 0). Вид: 377 000 → 3 770 000 → 3 720 000 — цена возвращается к базе
# следующей же точкой.
def test_drop_decimal_slips_drops_spike_up(caplog: pytest.LogCaptureFixture) -> None:
"""Точка ×10 с возвратом к базе выброшена; соседи по бокам целы."""
series = [19064000, 190150000, 19150000]
with caplog.at_level(logging.WARNING):
kept, dropped = drop_decimal_slips(series, lambda p: p, listing_id=406163)
assert kept == [19064000, 19150000]
assert dropped == 1
assert any("406163" in r.getMessage() and "19064000" in r.getMessage() for r in caplog.records)
def test_drop_decimal_slips_drops_spike_down() -> None:
"""Тот же критерий в другую сторону: ÷10 и возврат к базе (377 000 при базе 3.7 млн)."""
kept, dropped = drop_decimal_slips([3720000, 377000, 3770000], lambda p: p)
assert kept == [3720000, 3770000]
assert dropped == 1
def test_drop_decimal_slips_keeps_honest_doubling() -> None:
"""Рост ×2 — не сдвиг разряда, серия не тронута."""
series = [5000000, 10000000, 9900000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_keeps_tenfold_without_return_to_base() -> None:
"""×10 БЕЗ возврата к базе — возможное честное изменение цены, точку не трогаем.
Хвост 1 020 000 → 10 200 000 → 10 150 000 намеренно стоит НЕ первым: ровно та
же тройка в начале серии — это уже сдвиг по правилу первой точки (см.
test_drop_decimal_slips_drops_first_point_witnessed_by_third), и правила здесь
не спорят, а смотрят на разные концы серии. Внутри серии у 10 200 000 есть
база слева, и она НЕ подтверждена следующей точкой (9.95 ≠ 1) — точка честная.
"""
series = [1000000, 1020000, 10200000, 10150000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_last_point_checked_against_current_price() -> None:
"""У последней точки следующей нет — свидетель это текущая цена объявления."""
kept, dropped = drop_decimal_slips([1020000, 10200000], lambda p: p, current_price=1020000)
assert kept == [1020000]
assert dropped == 1
def test_drop_decimal_slips_last_point_kept_without_current_price() -> None:
"""Второго свидетеля нет вообще → точка остаётся (могла быть честной сменой цены)."""
series = [1020000, 10200000]
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
# ── правило ПЕРВОЙ точки (#3376, прод-разбор 06.09.2026) ─────────────────────
# Серии, у которых дефектная точка первая: 420 000 → 4 200 000 → 4 500 000. Слева
# базы нет, основное правило такую точку не видит. Свидетелей по-прежнему двое, и
# оба — из самой истории: вторая точка и ТРЕТЬЯ. Кандидатов первой точки на проде
# было 21, свидетеля из истории имеют 4 (все domklik).
def test_drop_decimal_slips_drops_first_point_witnessed_by_third() -> None:
"""420 000 → 4 200 000 → 4 500 000: вторую точку подтверждает третья."""
kept, dropped = drop_decimal_slips([420000, 4200000, 4500000], lambda p: p)
assert (kept, dropped) == ([4200000, 4500000], 1)
def test_drop_decimal_slips_keeps_first_point_when_series_runs_away() -> None:
"""Первая ×10 ко второй, но третья ушла ЕЩЁ дальше — это разгон цены, не разряд."""
series = [400000, 4000000, 8000000]
assert drop_decimal_slips(series, lambda p: p) == (series, 0)
def test_drop_decimal_slips_keeps_first_point_without_third_point() -> None:
"""330 000 → 3 300 000: третьей точки нет — свидетеля нет, ДАЖЕ с ценой лота.
Цена объявления в правиле первой точки не участвует сознательно: у 17 из 21
прод-кандидата (yandex 6, domklik 9, cian 2) listings.price_rub в точности
равнялась второй точке — у yandex это буквально одна переменная lot.price_rub,
записанная в двух местах. Такой «второй свидетель» — то же наблюдение, а
удаление точки необратимо. Верни COALESCE на current_price в правило первой
точки — первый assert покраснеет.
"""
series = [330000, 3300000]
assert drop_decimal_slips(series, lambda p: p, current_price=3300000) == (series, 0)
assert drop_decimal_slips(series, lambda p: p, current_price=None) == (series, 0)
def test_drop_decimal_slips_first_point_decided_on_kept_series() -> None:
"""Свидетели первой точки — ОСТАВШИЕСЯ соседи, а не сырые.
1M → 10M → 100M → 10M: 100M выбрасывает основное правило, и только после этого
видно, что первая точка ÷10 к оставшейся серии [10M, 10M]. Считай правило по
сырым соседям — первый проход оставил бы 1M, а второй выбросил, то есть гейт
перестал бы быть идемпотентным (перебор ниже ловит 1116 таких прогонов, и эта
серия — первый из них).
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 100_000_000, 10_000_000], lambda p: p
)
assert (kept, dropped) == ([10_000_000, 10_000_000], 2)
# ── база = предыдущая ОСТАВЛЕННАЯ точка (фальсификаторы ревьюера) ────────────
def test_drop_decimal_slips_alternating_series_keeps_honest_points() -> None:
"""1M → 10M → 1M → 10M при цене 1M: обе честные точки на месте.
Первая редакция миграции 286 брала базой СЫРУЮ предыдущую строку (lag) и на
этой серии оставляла [1M] вместо [1M, 1M] — удаляла честную точку.
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 1_000_000, 10_000_000], lambda p: p, current_price=1_000_000
)
assert (kept, dropped) == ([1_000_000, 1_000_000], 2)
def test_drop_decimal_slips_base_is_previous_kept_not_raw() -> None:
"""1M → 10M → 1.05M → 9.9M при цене 1M: после выброса 10M база для 1.05M — это 1M.
По сырой базе 1.05M тоже стала бы кандидатом (10M/1.05M = 9.52), а следующий
прогон доел бы 9.9M — ровно та неидемпотентность, которую нашёл ревьюер.
"""
kept, dropped = drop_decimal_slips(
[1_000_000, 10_000_000, 1_050_000, 9_900_000], lambda p: p, current_price=1_000_000
)
assert (kept, dropped) == ([1_000_000, 1_050_000, 9_900_000], 1)
# ── свойство: gate(gate(s)) == gate(s) ───────────────────────────────────────
_SLIP_ALPHABET = (1_000_000, 10_000_000, 100_000_000, 1_050_000, 9_900_000)
_SLIP_CURRENTS = (None, 1_000_000, 10_000_000)
def test_drop_decimal_slips_is_idempotent(caplog: pytest.LogCaptureFixture) -> None:
"""Повторный прогон гейта не трогает ничего — на всех сериях длины 2-6.
Свойство несущее, а не декоративное: миграция 286 зеркалит эту функцию
пошагово, а доказать идемпотентность на самом SQL-файле нечем — прод-прогон
один. Раз выборка кандидатов повторяет гейт, свойство переносится на неё.
ФАЛЬСИФИЦИРУЕМОСТЬ (перемерено 06.09.2026 на этом же переборе — 19 525 серий
× 3 цены = 58 575 прогонов):
• база = сырая предыдущая точка вместо оставленной → 136 неидемпотентных
прогонов, первый же — (1M, 10M, 1.05M, 9.9M) при цене 1M: [1M, 9.9M] → [1M];
• решение по первой точке на сырых соседях вместо оставшихся → 1116 прогонов,
первый — (1M, 10M, 100M, 10M) без текущей цены: [1M, 10M, 10M] → [10M, 10M].
"""
# Гейт логирует каждый выброс; на таком переборе это десятки тысяч записей.
caplog.set_level(logging.CRITICAL, logger="scraper_kit.offer_price_history")
for length in range(2, 7):
for series in itertools.product(_SLIP_ALPHABET, repeat=length):
for current in _SLIP_CURRENTS:
once, _ = drop_decimal_slips(list(series), lambda p: p, current_price=current)
twice, dropped_again = drop_decimal_slips(once, lambda p: p, current_price=current)
assert (twice, dropped_again) == (once, 0), (
f"серия {series} при текущей цене {current}: {once}{twice}"
)
# ── recompute_diff_percent — общий пересчёт после выброса ────────────────────
def test_recompute_diff_percent_from_surviving_neighbours() -> None:
"""Процент считается от оставшейся базы; у первой точки базы нет → NULL."""
changes: list[dict[str, object]] = [
{"price_rub": 3720000, "diff_percent": -89.87},
{"price_rub": 3770000, "diff_percent": 900.0},
]
recompute_diff_percent(changes)
assert [c["diff_percent"] for c in changes] == [None, 1.34]
def test_recompute_diff_percent_survives_none_price() -> None:
"""price_rub=None не роняет пересчёт: ручной ingest кладёт price_changes сырьём.
scripts/ingest_domclick_jsonl.py:108 берёт rec["price_changes"] из JSONL без
валидации — арифметика по None здесь дала бы TypeError на всю запись.
"""
changes: list[dict[str, object]] = [
{"price_rub": 5_000_000, "diff_percent": 12.0},
{"price_rub": None, "diff_percent": 900.0},
{"price_rub": 5_100_000, "diff_percent": -90.0},
]
recompute_diff_percent(changes)
assert [c["diff_percent"] for c in changes] == [None, None, 2.0]
def test_save_detail_enrichment_drops_decimal_slip_before_insert() -> None:
"""Писатель не отправляет спайк в INSERT, а соседу пересчитывает diff_percent."""
db = MagicMock()
db.execute.return_value.rowcount = 1
# SELECT price_rub FROM listings — текущая цена объявления (свидетель).
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=3770000)
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2, 3)]
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
price_changes=[
{"change_time": times[0], "price_rub": 3720000, "diff_percent": None},
# Сдвиг разряда: ÷10 к предыдущей, следующая возвращается к базе.
{"change_time": times[1], "price_rub": 377000, "diff_percent": -89.87},
{"change_time": times[2], "price_rub": 3770000, "diff_percent": 900.0},
],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert [c[0][1]["price"] for c in insert_calls] == [3720000, 3770000]
# У точки-соседа diff_percent парсер считал от исчезнувшей базы 377 000 (+900%,
# такое validate_diff_percent отвергает). От настоящей базы 3 720 000 это +1.34%.
assert insert_calls[1][0][1]["diff"] == 1.34
def test_save_detail_enrichment_witness_comes_only_from_listing_price() -> None:
"""Серия из ДВУХ точек: второго свидетеля даёт исключительно цена листинга.
Здесь чтение цены не декорация, а единственный вход гейта: у последней точки
следующей нет. Сломай его (RETURNING убран / fetchone → None) — свидетеля не
станет, спайк 10 200 000 уедет в offer_price_history, и тест покраснеет на
втором INSERT'е. Проверено подменой fetchone → None.
"""
db = MagicMock()
db.execute.return_value.rowcount = 1
db.execute.return_value.fetchone.return_value = MagicMock(price_rub=1020000)
times = [datetime(2026, 4, d, tzinfo=UTC) for d in (1, 2)]
e = DomClickDetailEnrichment(
item_id="x",
source_url=_CARD_URL,
price_changes=[
{"change_time": times[0], "price_rub": 1020000, "diff_percent": None},
{"change_time": times[1], "price_rub": 10200000, "diff_percent": 900.0},
],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert [c[0][1]["price"] for c in insert_calls] == [1020000]
# Цену отдаёт тот же UPDATE — отдельного SELECT по PK больше нет.
assert "RETURNING price_rub" in str(db.execute.call_args_list[0][0][0])
assert not [c for c in db.execute.call_args_list if "SELECT price_rub" in str(c[0][0])]
# ── canon_sale_type (#2674) ───────────────────────────────────────────────────
# Кейсы — ФАКТИЧЕСКИЙ словарь прода на 2026-08-06:
# SELECT source, sale_type, count(*) FROM listings GROUP BY 1,2 →
# domklik: «Свободная продажа» 5546 · «Альтернатива» 553 · «Первичная продажа» 2.
# Токены-цели — те, что уже пишут cian (free 14800 / alternative 1327) и
# avito (free 2976). Третий словарь не заводим.
@pytest.mark.parametrize(
("raw", "expected"),
[
("Свободная продажа", "free"),
("Альтернатива", "alternative"),
# Регистр/пробелы источника не должны плодить новые значения в колонке.
("свободная продажа", "free"),
(" Альтернатива ", "alternative"),
# Соответствия в чужих словарях нет: первичка у cian — это ТИП ДОГОВОРА
# (fz214/dzhsk/pdkp/dupt), маркетинговая метка его не называет. Не угадываем.
("Первичная продажа", None),
# Незнакомое/пустое → None → COALESCE в UPDATE не затрёт существующее.
("Что-то новое", None),
("", None),
(None, None),
],
)
def test_canon_sale_type_covers_every_prod_value(raw: str | None, expected: str | None) -> None:
assert canon_sale_type(raw) == expected
def test_canon_sale_type_is_idempotent_on_canon_tokens() -> None:
"""Токен уже каноничен (повторное обогащение той же строки) → не теряем его."""
assert canon_sale_type("free") is None # чужой токен на вход не подаётся…
# …а вот через save_detail_enrichment COALESCE защищает уже записанное:
db = MagicMock()
db.execute.return_value.rowcount = 1
e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, sale_type="Первичная продажа")
save_detail_enrichment(db, 1, e)
params = db.execute.call_args_list[0][0][1]
assert params["sale_type"] is None
assert "COALESCE(:sale_type, sale_type)" in str(db.execute.call_args_list[0][0][0])