Compare commits
1 commit
main
...
fix/2855-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f967533272 |
2 changed files with 122 additions and 15 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue