fix(tradein/domclick): единый список QRATOR-маркеров serp+detail (#2636) #2645

Merged
bot-backend merged 1 commit from fix/domclick-qrator-markers into main 2026-08-05 07:06:42 +00:00
4 changed files with 57 additions and 24 deletions

View file

@ -159,6 +159,25 @@ def test_extract_ssr_state_challenge_raises_blocked() -> None:
_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')

View file

@ -1,12 +1,28 @@
"""DomClick-specific exceptions для anti-bot detection."""
# ── Канонический список anti-bot маркеров (QRATOR/DataDome) ──────────────────
# Единый источник для Layer A (providers/domclick/serp.py) и Layer B
# (providers/domclick/detail.py) — были два расходящихся списка (#2636), теперь
# оба слоя импортируют этот. Сравнение case-insensitive: caller лоуеркейзит HTML
# перед `marker in html_lower`.
DOMCLICK_BLOCK_MARKERS: tuple[str, ...] = (
"qrator",
"bot_mitigation",
"система защиты",
"403 | домклик",
"captcha",
"access denied",
"datadome",
)
class DomClickBlockedError(Exception):
"""DomClick BFF вернул QRATOR block-страницу (HTTP 200 + block HTML).
QRATOR (qrator.net) WAF/DDoS-защита domclick.ru. Блокирует datacenter-IP
и возвращает HTML с маркерами: "qrator", "bot_mitigation", "система защиты",
"403 | домклик", "captcha", "access denied".
и возвращает HTML с маркерами: см. `DOMCLICK_BLOCK_MARKERS` в этом модуле
("qrator", "bot_mitigation", "система защиты", "403 | домклик", "captcha",
"access denied", "datadome").
Обходится через shared mobile proxy (BrowserFetcher(source="domclick")
generic provider мобильный egress).

View file

@ -56,7 +56,11 @@ from urllib.parse import urlsplit
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.domclick_exceptions import (
DOMCLICK_BLOCK_MARKERS,
DomClickBlockedError,
DomClickParseError,
)
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text,
@ -68,11 +72,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# ── Маркеры anti-bot challenge (DataDome / QRATOR) ───────────────────────────
# Зеркало идеи Layer A: если __SSR_STATE__ отсутствует И страница похожа на
# challenge — это блок, а не parse-failure.
_BLOCK_MARKERS: tuple[str, ...] = ("qrator", "captcha", "datadome", "access denied")
# ── item_id из URL карточки: .../card/sale__flat__2075729321 ─────────────────
_ITEM_ID_RE = re.compile(r"sale__flat__(\d+)")
@ -180,11 +179,20 @@ def _extract_ssr_state(html: str) -> dict[str, Any]:
match = _SSR_ASSIGN_RE.search(html)
if match is None:
html_lower = html.lower()
if any(marker in html_lower for marker in _BLOCK_MARKERS):
if any(marker in html_lower for marker in DOMCLICK_BLOCK_MARKERS):
raise DomClickBlockedError(
"DomClick detail: challenge page detected (no __SSR_STATE__, "
f"markers checked: {_BLOCK_MARKERS})"
f"markers checked: {DOMCLICK_BLOCK_MARKERS})"
)
# Ни SSR-стейта, ни известного anti-bot маркера — либо честный parse-failure
# (дрейф схемы), либо новый вариант блок-страницы, которого нет в
# DOMCLICK_BLOCK_MARKERS (#2636: расхождение списков привело к misclassify
# blocked→failed). Логируем голову HTML, чтобы дрейф маркеров был виден.
logger.warning(
"domclick_detail: __SSR_STATE__ not found, no known block marker — "
"head=%r",
html[:300].replace("\n", " "),
)
raise DomClickParseError("__SSR_STATE__ not found")
brace_start = html.find("{", match.end())

View file

@ -36,7 +36,7 @@ from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from scraper_kit.base import BaseScraper, ScrapedLot
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.domclick_exceptions import DOMCLICK_BLOCK_MARKERS, DomClickBlockedError
from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
@ -80,17 +80,6 @@ _DOMCLICK_BISECTION = BisectionConfig(
open_split_floor=0,
)
# ── QRATOR block detection ────────────────────────────────────────────────────
_QRATOR_MARKERS: tuple[str, ...] = (
"qrator",
"bot_mitigation",
"система защиты",
"403 | домклик",
"captcha",
"access denied",
)
# ── EKB geo guard ─────────────────────────────────────────────────────────────
_EKB_LAT_MIN: float = 56.6
@ -119,9 +108,10 @@ def _extract_json(html: str) -> dict[str, Any]:
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
# стоять за пределами head в крупных challenge-страницах.
html_lower = html.lower()
if any(m in html_lower for m in _QRATOR_MARKERS):
if any(m in html_lower for m in DOMCLICK_BLOCK_MARKERS):
raise DomClickBlockedError(
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
"DomClick BFF: QRATOR block page detected "
f"(markers checked: {DOMCLICK_BLOCK_MARKERS[:2]})"
)
start = html.find("{")