fix(tradein/proxy): проба Домклика спрашивает защищённый путь, а не robots.txt
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 4m12s

Проба и работа делили транспорт и хост, но не РЕСУРС: проба брала
bff-search-web.domclick.ru/robots.txt, работа — /api/offers/v1. QRATOR закрывает
API и не трогает robots.txt, поэтому проба отвечала 200 «пара здорова» ровно
тогда, когда свип с того же узла получал блок-страницу.

Прод 13.08: за сутки 64 проверки пар и 5 банов на ВСЕ четыре площадки, при том
что свип Домклика блокируется каждые сутки. В 04:30 healthcheck пишет
pair_banned=0, в 05:02 свип — QRATOR block и узел 11 в бан на шесть часов.
Зелёный ответ здесь не бесполезен, а вреден: читается как разрешение выдать узел.

Адрес пробы — count-эндпоинт (/api/offers/count/v1): та же защищённая семья
путей, что у работы, но ответ одно число, без пагинации и выдачи. Нагрузка на
площадку минимальна — тот же довод, по которому здесь /fetch, а не /fetch-json.

Признак «ресурс отдан» стал зависеть от площадки: 'User-agent' у
robots.txt-источников, 'snippetsCount' у Домклика. Отдельной картой, а не
условием в теле, чтобы добавление площадки было одной строкой.

Знание было в коде на шаг от вывода: п.4 шапки test_2800 уже гласил
«robots.txt площадка отдаёт и забаненному IP». Следствие обработали (проба не
гасит боевой бан), причину — нет.

Красный прогон: 4 падения на origin/main тем же файлом теста, 18 зелёных на
ветке. Смежные — 85 passed.

Refs #2855, #2657
This commit is contained in:
bot-backend 2026-08-13 10:52:21 +05:00
parent 4b527751b9
commit f967533272
2 changed files with 122 additions and 15 deletions

View file

@ -153,28 +153,38 @@ async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.Monke
@pytest.mark.parametrize(
("source", "must_contain"),
("source", "must_contain", "path_suffix"),
[
("avito", "www.avito.ru"),
("cian", "ekb.cian.ru"),
("yandex", "realty.yandex.ru"),
("avito", "www.avito.ru", "/robots.txt"),
("cian", "ekb.cian.ru", "/robots.txt"),
("yandex", "realty.yandex.ru", "/robots.txt"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
("domclick", "bff-search-web.domclick.ru"),
#
# #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*,
# robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4
# в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по
# нему зелёная ровно тогда, когда свип получает блок.
("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"),
],
)
async def test_probe_asks_the_working_host_of_each_source(
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str
) -> None:
seen: dict[str, Any] = {}
# Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» —
# 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа.
_body = (
'{"result":{"snippetsCount":678}}' if source == "domclick" else "<pre>User-agent: *</pre>"
)
class _Resp:
status_code = 200
text = '{"html": "<pre>User-agent: *</pre>"}'
text = "{}"
@staticmethod
def json() -> dict[str, str]:
return {"html": "<pre>User-agent: *</pre>"}
return {"html": _body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
@ -196,7 +206,10 @@ async def test_probe_asks_the_working_host_of_each_source(
assert ok is True
assert must_contain in seen["payload"]["url"]
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
# #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это
# боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть
# нагрузка на площадку остаётся минимальной, а защита — той же, что у работы.
assert path_suffix in seen["payload"]["url"]
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "generic"
@ -233,7 +246,10 @@ async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.Monkey
assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page"
assert "not robots.txt" in detail
# #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от
# площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика).
# Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили.
assert "no marker 'User-agent'" in detail
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
@ -454,3 +470,72 @@ async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPat
lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None
release(db, lease.id) # type: ignore[arg-type]
# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ──
@pytest.mark.parametrize(
("api_body", "expect_ok"),
[
# Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом.
# На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True,
# то есть «пара здорова» ровно там, где свип получает блок. Красный на main.
("<html><title>Ошибка</title>qrator captcha</html>", False),
# Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность
# ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай
# и провалила бы этот, то есть тест не может позеленеть от глухого отказа.
# На origin/main этот случай тоже красный, но по другой причине — там проба
# спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА.
('{"result":{"snippetsCount":678}}', True),
],
)
async def test_domclick_probe_sees_the_block_that_robots_txt_hides(
monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool
) -> None:
"""Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок.
Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` 05:02 свип
`QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и
5 банов на все четыре площадки при ежедневном блоке Домклика.
"""
seen: dict[str, Any] = {}
class _Resp:
status_code = 200
text = "{}"
def __init__(self, body: str) -> None:
self._body = body
def json(self) -> dict[str, str]:
return {"html": self._body}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
seen["payload"] = json
asked = str(json["url"])
# Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт.
if asked.endswith("/robots.txt"):
return _Resp("<pre>User-agent: *</pre>")
return _Resp(api_body)
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
ok, fail_kind, _detail = await bf.probe_proxy_via_browser(
"http://tradein-browser:3000", "http://u:p@node:8080", source="domclick"
)
assert ok is expect_ok
if not expect_ok:
# Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy".
assert fail_kind == "page"
# Спрашивали именно боевой путь, а не robots.txt того же хоста.
assert "/api/offers/" in seen["payload"]["url"]

View file

@ -61,7 +61,22 @@ _PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url
"yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL
"domclick": "https://bff-search-web.domclick.ru/robots.txt",
# #2855: боевой ЗАЩИЩЁННЫЙ путь, а не robots.txt того же хоста. QRATOR закрывает
# /api/offers/*, а robots.txt отдаётся свободно — проба по нему возвращала 200 ровно
# тогда, когда свип с того же узла получал блок-страницу. Замер 13.08: за сутки
# 64 проверки пар и 5 банов на ВСЕ площадки, при этом свип Домклика блокировался
# каждые сутки; 04:30 проба «pair_banned=0» → 05:02 свип «QRATOR block».
# Взят count-эндпоинт: та же семья путей, что у работы (providers/domclick/serp.py
# ::_build_count_url), но ответ — одно число, без пагинации и без выдачи, то есть
# проба остаётся дешёвой для площадки (тот же довод, по которому здесь /fetch, а
# не /fetch-json). Параметры продублированы намеренно: импорт провайдера сюда дал
# бы цикл (providers/* уже импортируют browser_fetcher).
"domclick": (
"https://bff-search-web.domclick.ru/api/offers/count/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=st"
"&sort=qi&sort_dir=desc"
),
}
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
@ -75,6 +90,11 @@ PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent"
# #2855: источник, чья проба идёт НЕ по robots.txt, требует своего признака «ресурс
# отдан». У count-эндпоинта Домклика это ключ ответа `snippetsCount` — он есть в живом
# JSON и отсутствует в блок-странице QRATOR. Держим отдельной картой, а не условием в
# теле: добавить площадку с боевым путём должно быть одной строкой, а не веткой.
_PROBE_CONTENT_MARKERS: dict[str, str] = {"domclick": "snippetsCount"}
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
@ -220,14 +240,16 @@ async def probe_proxy_via_browser(
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
if _PROBE_CONTENT_MARKER not in html:
# 200 и непустое тело, но это не robots.txt — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»).
marker = _PROBE_CONTENT_MARKERS.get(source, _PROBE_CONTENT_MARKER)
if marker not in html:
# 200 и непустое тело, но это не запрошенный ресурс — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»;
# 13.08: Домклик отдаёт блок-страницу QRATOR на /api/offers/*, #2855).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return (
False,
"page",
f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
f"no marker {marker!r} (html_len={len(html)}): {' '.join(html.split())[:120]}",
)
return True, None, f"html_len={len(html)}"