diff --git a/tradein-mvp/backend/tests/test_3267_block_marker_vs_listing_text.py b/tradein-mvp/backend/tests/test_3267_block_marker_vs_listing_text.py new file mode 100644 index 00000000..19856552 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3267_block_marker_vs_listing_text.py @@ -0,0 +1,80 @@ +"""#3267: маркер блока не должен срабатывать на тексте объявления. + +Прод 30.08, прогон 5363: продавец написал в описании квартиры «Система защиты от +протечек». Подстрока совпала с маркером блока «система защиты», ответ на 107 КБ с +двадцатью валидными офферами был объявлен блок-страницей QRATOR, свип оборвал ВСЕ +комнатные корзины и забанил живой узел пула на 6 часов. + +Причина — порядок проверок: маркеры искались подстрокой по всему телу ДО разбора +JSON. Разобранный JSON нужной формы блок-страницей быть не может (QRATOR отдаёт +HTML), поэтому валидный разбор сам по себе доказывает отсутствие блока. Тот же +порядок давно применён в detail.py — там маркеры смотрят только когда __SSR_STATE__ +не найден. +""" + +from __future__ import annotations + +import json +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from scraper_kit.domclick_exceptions import DOMCLICK_BLOCK_MARKERS, DomClickBlockedError +from scraper_kit.providers.domclick.serp import _extract_json + + +@pytest.mark.parametrize("маркер", sorted(DOMCLICK_BLOCK_MARKERS)) +def test_marker_inside_listing_description_is_not_a_block(маркер: str) -> None: + """Любой маркер в пользовательском тексте не превращает валидный ответ в блок.""" + payload = { + "result": { + "items": [ + {"id": 1, "description": f"Отличная квартира. {маркер.capitalize()} от протечек."} + ] + } + } + data = _extract_json(json.dumps(payload, ensure_ascii=False)) + assert data["result"]["items"][0]["id"] == 1 + + +def test_real_prod_description_from_run_5363() -> None: + """Дословный фрагмент объявления, на котором оборвался прод-прогон 5363.""" + payload = { + "result": { + "items": [ + { + "id": 2078475983, + "description": ( + "но всё до миллиметра. Это не просто ремонт, а продуманный " + "дизайн-проект:\r\n🔹 Двери скрытого монтажа с магнитными " + "стопперами\r\n🔹 Система защиты от протечек\r\n🔹 Встроенная " + "мебель и качественная техника" + ), + } + ] + } + } + assert _extract_json(json.dumps(payload, ensure_ascii=False))["result"]["items"] + + +def test_actual_block_page_still_raises() -> None: + """Настоящая блок-страница — HTML без разбираемого JSON — по-прежнему блок.""" + html = ( + "
."""
+ data = _extract_json('{"result": {"items": []}}')
+ assert data == {"result": {"items": []}}
+
+
+def test_garbage_without_markers_is_a_parse_error_not_a_block() -> None:
+ """Мусор без маркеров — ValueError, а не блок: узел за это банить нельзя."""
+ with pytest.raises(ValueError, match="JSON"):
+ _extract_json("что-то пошло не так")
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
index ac458a2a..441ba041 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
@@ -106,8 +106,32 @@ def _extract_json(html: str) -> dict[str, Any]:
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
ValueError: если JSON не найден или не является dict.
"""
- # Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
- # стоять за пределами head в крупных challenge-страницах.
+ # Порядок важен: СНАЧАЛА пробуем разобрать JSON, и только если не вышло — ищем
+ # маркеры блока (#3267). Раньше было наоборот, и маркеры искались подстрокой по
+ # ВСЕМУ телу, включая пользовательские описания объявлений. Прод 30.08, прогон
+ # 5363: продавец написал в описании квартиры «Система защиты от протечек» —
+ # подстрока совпала с маркером «система защиты», ответ на 107 КБ с двадцатью
+ # валидными офферами был объявлен блок-страницей, свип оборвал все корзины и
+ # забанил живой узел пула на 6 часов. Совпасть так же могут «captcha» и «qrator»
+ # — в тексте объявления, в имени агентства, в ссылке.
+ #
+ # Разобранный JSON нужной формы блок-страницей быть не может: QRATOR отдаёт HTML.
+ # Поэтому валидный разбор — сам по себе доказательство, что блока нет, и никакие
+ # маркеры его переспорить не должны. Тот же порядок уже применён в detail.py
+ # (маркеры смотрят ТОЛЬКО когда __SSR_STATE__ не найден).
+ start = html.find("{")
+ end = html.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ try:
+ data = json.loads(html[start : end + 1])
+ except json.JSONDecodeError:
+ data = None
+ if isinstance(data, dict):
+ return data # type: ignore[return-value]
+
+ # JSON не разобрался — вот теперь проверяем, не блок ли это. Сканируем ВЕСЬ ответ
+ # (а не только первые 4096 Б): block-маркер может стоять за пределами head в
+ # крупных challenge-страницах.
html_lower = html.lower()
if any(m in html_lower for m in DOMCLICK_BLOCK_MARKERS):
raise DomClickBlockedError(
@@ -115,15 +139,9 @@ def _extract_json(html: str) -> dict[str, Any]:
f"(markers checked: {DOMCLICK_BLOCK_MARKERS[:2]})"
)
- start = html.find("{")
- end = html.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
-
- data = json.loads(html[start : end + 1])
- if not isinstance(data, dict):
- raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
- return data # type: ignore[return-value]
+ raise ValueError(f"BFF response is not a JSON dict (len={len(html)})")
# ── URL builders ──────────────────────────────────────────────────────────────