From f967533272a14e923ecc31c201b092d6462a3822 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 10:52:21 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/proxy):=20=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=20=D0=94=D0=BE=D0=BC=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=89=D0=B8=D1=89=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D1=8C,=20=D0=B0=20=D0=BD=D0=B5=20robots.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проба и работа делили транспорт и хост, но не РЕСУРС: проба брала 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 --- .../tests/test_2800_per_source_probe.py | 105 ++++++++++++++++-- .../src/scraper_kit/browser_fetcher.py | 32 +++++- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py index 37fc10fe..7fa59d45 100644 --- a/tradein-mvp/backend/tests/test_2800_per_source_probe.py +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -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 "
User-agent: *
" + ) class _Resp: status_code = 200 - text = '{"html": "
User-agent: *
"}' + text = "{}" @staticmethod def json() -> dict[str, str]: - return {"html": "
User-agent: *
"} + 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. + ("Ошибкаqrator captcha", 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("
User-agent: *
") + 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"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 034de30f..e5421aec 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -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)}" -- 2.45.3