From aec05280faa5e49e1c2d8cdeb233297a605cd8d1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 11:49:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=C2=AB=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BE=D1=82=20=D0=BF=D1=80=D0=BE=D1=82=D0=B5=D1=87?= =?UTF-8?q?=D0=B5=D0=BA=C2=BB=20=D0=B2=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=87=D0=B8=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=8C=20=D0=BA=D0=B0=D0=BA=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20QRATOR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _extract_json искал маркеры блока подстрокой по ВСЕМУ телу ответа — до разбора JSON, то есть и по пользовательским описаниям объявлений. Прод 30.08, прогон 5363: продавец написал в описании квартиры «🔹 Система защиты от протечек». Подстрока совпала с маркером «система защиты», ответ на 107 183 байта с двадцатью валидными офферами был объявлен блок- страницей, свип оборвал все комнатные корзины и забанил живой узел пула на 6 часов. Совпасть так же могут «captcha» и «qrator» — в тексте объявления, в имени агентства, в ссылке. Порядок перевёрнут: сначала разбор JSON, маркеры — только если разбор не удался. Разобранный JSON нужной формы блок-страницей быть не может, QRATOR отдаёт HTML, так что валидный разбор сам по себе доказывает отсутствие блока. Тот же порядок давно применён в detail.py — там маркеры смотрят только когда __SSR_STATE__ не найден; свип был единственным местом с обратной логикой. Мусор без маркеров теперь ValueError, а не блок: за неразобранный ответ неизвестной природы узел банить нельзя. Тест проверен мутацией: со старым порядком 8 проверок из 11 краснеют, включая дословный фрагмент описания из прогона 5363. --- .../test_3267_block_marker_vs_listing_text.py | 80 +++++++++++++++++++ .../scraper_kit/providers/domclick/serp.py | 36 ++++++--- 2 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3267_block_marker_vs_listing_text.py 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 = ( + "403 | ДомКлик" + "Система защиты QRATOR: доступ ограничен" + ) + with pytest.raises(DomClickBlockedError): + _extract_json(html) + + +def test_html_wrapped_json_still_parses() -> None: + """Прежний путь: сайдкар в режиме навигации оборачивал JSON в
."""
+    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 ──────────────────────────────────────────────────────────────