fix(tradein/domclick): «система защиты от протечек» в объявлении читалась как блок QRATOR #3268

Merged
lekss361 merged 2 commits from fix/3267-block-marker-in-listing-text into main 2026-08-30 09:14:18 +00:00
2 changed files with 107 additions and 9 deletions

View file

@ -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 = (
"<html><head><title>403 | ДомКлик</title></head>"
"<body>Система защиты QRATOR: доступ ограничен</body></html>"
)
with pytest.raises(DomClickBlockedError):
_extract_json(html)
def test_html_wrapped_json_still_parses() -> None:
"""Прежний путь: сайдкар в режиме навигации оборачивал JSON в <pre>."""
data = _extract_json('<html><body><pre>{"result": {"items": []}}</pre></body></html>')
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("<html><body>что-то пошло не так</body></html>")

View file

@ -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 ──────────────────────────────────────────────────────────────