fix(tradein/proxy): проба спрашивает каждую площадку, вердикт пишется на пару «узел × источник» (#2800 B) (#2803)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m0s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m0s
This commit is contained in:
parent
7cd8c63b89
commit
08bb9d6549
5 changed files with 717 additions and 51 deletions
|
|
@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
|
||||||
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
||||||
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
|
||||||
|
|
||||||
|
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
|
||||||
|
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
|
||||||
|
для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв»
|
||||||
|
вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500
|
||||||
|
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
|
||||||
|
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
|
||||||
|
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
|
||||||
|
достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки
|
||||||
|
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
|
||||||
|
`bff-search-web.domclick.ru`, куда ходит сбор, — 500).
|
||||||
|
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
|
||||||
|
сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый
|
||||||
|
отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки.
|
||||||
|
Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt
|
||||||
|
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
|
||||||
|
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
|
||||||
|
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
|
||||||
|
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
|
||||||
|
креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла.
|
||||||
|
Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками
|
||||||
|
вернули то самое схлопывание диагнозов.
|
||||||
|
|
||||||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -125,6 +147,7 @@ __all__ = [
|
||||||
"mark_banned",
|
"mark_banned",
|
||||||
"mark_browser_health",
|
"mark_browser_health",
|
||||||
"mark_health",
|
"mark_health",
|
||||||
|
"mark_source_probe",
|
||||||
"reap_stale_leases",
|
"reap_stale_leases",
|
||||||
"release",
|
"release",
|
||||||
"run_proxy_healthcheck",
|
"run_proxy_healthcheck",
|
||||||
|
|
@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
|
||||||
# намеренно не обновляется (см. mark_browser_health).
|
# намеренно не обновляется (см. mark_browser_health).
|
||||||
BROWSER_UNFIT_THRESHOLD = 2
|
BROWSER_UNFIT_THRESHOLD = 2
|
||||||
|
|
||||||
|
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
|
||||||
|
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
|
||||||
|
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
|
||||||
|
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
|
||||||
|
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
|
||||||
|
# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
|
||||||
|
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
|
||||||
|
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
|
||||||
|
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
|
||||||
|
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
|
||||||
|
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
|
||||||
|
# acquire снова выдаёт узел, не спросив.
|
||||||
|
#
|
||||||
|
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
|
||||||
|
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
|
||||||
|
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
|
||||||
|
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
|
||||||
|
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
|
||||||
|
_PROBE_BAN_REASON = "probe:browser"
|
||||||
|
|
||||||
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
|
||||||
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||||
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||||
|
|
@ -682,9 +725,14 @@ def mark_browser_health(
|
||||||
return "fail"
|
return "fail"
|
||||||
|
|
||||||
|
|
||||||
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None:
|
||||||
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
|
||||||
|
|
||||||
|
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
|
||||||
|
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы —
|
||||||
|
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
|
||||||
|
clear_source_bans умеет фильтровать по ней (`only_reason`).
|
||||||
|
|
||||||
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||||
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||||
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||||
|
|
@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
{
|
{
|
||||||
"proxy_id": proxy_id,
|
"proxy_id": proxy_id,
|
||||||
"source": source,
|
"source": source,
|
||||||
"reason": f"banned:{source}",
|
"reason": reason or f"banned:{source}",
|
||||||
"base_hours": SOURCE_BAN_BASE_HOURS,
|
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||||
"max_hours": SOURCE_BAN_MAX_HOURS,
|
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||||
"max_fails": MAX_CONSECUTIVE_FAILS,
|
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||||
|
|
@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
|
def clear_source_bans(
|
||||||
|
db: Session,
|
||||||
|
proxy_id: int,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
reason: str,
|
||||||
|
only_reason: str | None = None,
|
||||||
|
) -> int:
|
||||||
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
|
||||||
|
|
||||||
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||||
|
|
@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
SOURCE_BAN_BASE_HOURS.
|
SOURCE_BAN_BASE_HOURS.
|
||||||
|
|
||||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||||
|
|
||||||
|
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||||
|
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||||
|
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
|
||||||
|
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
|
||||||
|
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
|
||||||
|
целиком. None — снимать всё, как и раньше.
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
DELETE FROM scrape_proxy_source_bans
|
DELETE FROM scrape_proxy_source_bans
|
||||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||||
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
|
||||||
|
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
|
||||||
RETURNING source
|
RETURNING source
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"proxy_id": proxy_id, "source": source},
|
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
db.commit()
|
db.commit()
|
||||||
if rows:
|
if rows:
|
||||||
|
|
@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_source_probe(
|
||||||
|
db: Session,
|
||||||
|
proxy_id: int,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
ok: bool,
|
||||||
|
fail_kind: str | None = None,
|
||||||
|
detail: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
|
||||||
|
|
||||||
|
Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
|
||||||
|
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
|
||||||
|
`acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим.
|
||||||
|
|
||||||
|
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
|
||||||
|
другая — здесь судится ПАРА, а не узел):
|
||||||
|
- "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored".
|
||||||
|
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
|
||||||
|
- "proxy" — через этот узел до площадки не доходит транспорт
|
||||||
|
(NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары;
|
||||||
|
- "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
|
||||||
|
(200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары.
|
||||||
|
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
|
||||||
|
ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя.
|
||||||
|
|
||||||
|
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
|
||||||
|
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
|
||||||
|
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре.
|
||||||
|
|
||||||
|
Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего
|
||||||
|
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
|
||||||
|
пишется (голодание хуже работы через плохой узел).
|
||||||
|
|
||||||
|
Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored".
|
||||||
|
"""
|
||||||
|
if ok:
|
||||||
|
cleared = clear_source_bans(
|
||||||
|
db,
|
||||||
|
proxy_id,
|
||||||
|
source=source,
|
||||||
|
reason=f"browser probe OK for source={source} ({detail})",
|
||||||
|
only_reason=_PROBE_BAN_REASON,
|
||||||
|
)
|
||||||
|
return "cleared" if cleared else "ok"
|
||||||
|
|
||||||
|
if fail_kind not in ("proxy", "page"):
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
|
||||||
|
"(fail_kind=%s): %s — вердикт не пишем",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
fail_kind,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
return "ignored"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
|
||||||
|
"пары, узел остаётся первосортным для остальных площадок (#2800)",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
fail_kind,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
|
||||||
|
return "banned"
|
||||||
|
|
||||||
|
|
||||||
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
|
||||||
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
||||||
|
|
||||||
|
|
@ -976,8 +1108,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
||||||
return False, None, None, "other"
|
return False, None, None, "other"
|
||||||
|
|
||||||
|
|
||||||
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
|
def _probe_sources_for(affinity: str) -> list[str]:
|
||||||
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
|
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
|
||||||
|
|
||||||
|
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
|
||||||
|
Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику,
|
||||||
|
которой никто не воспользуется.
|
||||||
|
|
||||||
|
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
|
||||||
|
свободных нет) — такая пара останется без вердикта и решится как раньше, по факту
|
||||||
|
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
|
||||||
|
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
|
||||||
|
бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4.
|
||||||
|
"""
|
||||||
|
from scraper_kit.browser_fetcher import PROBE_SOURCES
|
||||||
|
|
||||||
|
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_pair_probes(
|
||||||
|
db: Session, proxy_id: int, url: str, kind: str, affinity: str
|
||||||
|
) -> tuple[str, dict[str, int]]:
|
||||||
|
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
|
||||||
|
|
||||||
|
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
|
||||||
|
|
||||||
|
Два уровня вердикта, и они не пересекаются:
|
||||||
|
- ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке
|
||||||
|
отдельно, это то, что читает `acquire(source)`;
|
||||||
|
- УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по
|
||||||
|
итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел
|
||||||
|
работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки
|
||||||
|
узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов
|
||||||
|
появилось бы новое.
|
||||||
|
|
||||||
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
||||||
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
||||||
|
|
@ -985,18 +1148,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) ->
|
||||||
"""
|
"""
|
||||||
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
from scraper_kit.browser_fetcher import probe_proxy_via_browser
|
||||||
|
|
||||||
try:
|
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
|
||||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
fail_kinds: list[str] = []
|
||||||
_settings.browser_http_endpoint, url, proxy_kind=kind
|
any_ok = False
|
||||||
|
last_detail = ""
|
||||||
|
|
||||||
|
for source in _probe_sources_for(affinity):
|
||||||
|
try:
|
||||||
|
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||||
|
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||||
|
)
|
||||||
|
if not ok and fail_kind == "proxy":
|
||||||
|
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
|
||||||
|
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
|
||||||
|
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
|
||||||
|
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
|
||||||
|
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
|
||||||
|
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||||
|
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
|
||||||
|
proxy_id,
|
||||||
|
source,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
counters["pair_checked"] += 1
|
||||||
|
last_detail = detail
|
||||||
|
if ok:
|
||||||
|
any_ok = True
|
||||||
|
else:
|
||||||
|
fail_kinds.append(fail_kind or "other")
|
||||||
|
outcome = mark_source_probe(
|
||||||
|
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
|
||||||
)
|
)
|
||||||
except Exception:
|
if outcome == "banned":
|
||||||
logger.warning(
|
counters["pair_banned"] += 1
|
||||||
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
|
elif outcome == "cleared":
|
||||||
proxy_id,
|
counters["pair_cleared"] += 1
|
||||||
exc_info=True,
|
|
||||||
)
|
if counters["pair_checked"] == 0:
|
||||||
return "ignored"
|
return "ignored", counters # крест не состоялся — узел не судим
|
||||||
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
|
|
||||||
|
if any_ok:
|
||||||
|
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
|
||||||
|
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
|
||||||
|
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
|
||||||
|
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
|
||||||
|
return (
|
||||||
|
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
|
||||||
|
counters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mask(url: str) -> str:
|
def _mask(url: str) -> str:
|
||||||
|
|
@ -1032,18 +1237,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||||
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||||
|
|
||||||
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
|
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
|
||||||
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
|
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
|
||||||
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
|
САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
|
||||||
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
|
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
|
||||||
никогда не смешивается с consecutive_fails/enabled. Гейт — settings.
|
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
|
||||||
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
|
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
|
||||||
измеряла бы то, чем никто не пользуется.
|
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
|
||||||
|
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
|
||||||
|
проба измеряла бы то, чем никто не пользуется.
|
||||||
|
|
||||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||||
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
|
||||||
browser_unfit, browser_refit}.
|
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
|
||||||
"""
|
"""
|
||||||
reaped = reap_stale_leases(db)
|
reaped = reap_stale_leases(db)
|
||||||
|
|
||||||
|
|
@ -1051,7 +1258,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT id, url, kind, enabled, disabled_reason,
|
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
|
||||||
(browser_check_at IS NULL
|
(browser_check_at IS NULL
|
||||||
OR browser_check_at < now() - make_interval(
|
OR browser_check_at < now() - make_interval(
|
||||||
mins => CAST(:browser_probe_minutes AS integer)
|
mins => CAST(:browser_probe_minutes AS integer)
|
||||||
|
|
@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
browser_ok = 0
|
browser_ok = 0
|
||||||
browser_unfit = 0
|
browser_unfit = 0
|
||||||
browser_refit = 0
|
browser_refit = 0
|
||||||
|
pair_checked = 0
|
||||||
|
pair_banned = 0
|
||||||
|
pair_cleared = 0
|
||||||
for row in proxies:
|
for row in proxies:
|
||||||
proxy_id = int(row["id"])
|
proxy_id = int(row["id"])
|
||||||
url = str(row["url"])
|
url = str(row["url"])
|
||||||
|
|
@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
||||||
# не тот узел», из-за которого #2723 и появилась.
|
# не тот узел», из-за которого #2723 и появилась.
|
||||||
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
|
||||||
outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
|
outcome, pair_counters = await _run_pair_probes(
|
||||||
|
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
|
||||||
|
)
|
||||||
browser_checked += 1
|
browser_checked += 1
|
||||||
|
pair_checked += pair_counters["pair_checked"]
|
||||||
|
pair_banned += pair_counters["pair_banned"]
|
||||||
|
pair_cleared += pair_counters["pair_cleared"]
|
||||||
if outcome in ("ok", "refit"):
|
if outcome in ("ok", "refit"):
|
||||||
browser_ok += 1
|
browser_ok += 1
|
||||||
if outcome == "refit":
|
if outcome == "refit":
|
||||||
|
|
@ -1142,7 +1357,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
|
||||||
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
|
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
|
||||||
|
"pair_checked=%d pair_banned=%d pair_cleared=%d",
|
||||||
reaped,
|
reaped,
|
||||||
checked,
|
checked,
|
||||||
ok_count,
|
ok_count,
|
||||||
|
|
@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
browser_ok,
|
browser_ok,
|
||||||
browser_unfit,
|
browser_unfit,
|
||||||
browser_refit,
|
browser_refit,
|
||||||
|
pair_checked,
|
||||||
|
pair_banned,
|
||||||
|
pair_cleared,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"reaped": reaped,
|
"reaped": reaped,
|
||||||
|
|
@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
||||||
"browser_ok": browser_ok,
|
"browser_ok": browser_ok,
|
||||||
"browser_unfit": browser_unfit,
|
"browser_unfit": browser_unfit,
|
||||||
"browser_refit": browser_refit,
|
"browser_refit": browser_refit,
|
||||||
|
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
|
||||||
|
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
|
||||||
|
# «браузер через узел работает, но две площадки его не пускают».
|
||||||
|
"pair_checked": pair_checked,
|
||||||
|
"pair_banned": pair_banned,
|
||||||
|
"pair_cleared": pair_cleared,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,12 +397,18 @@ class FakeSession:
|
||||||
)
|
)
|
||||||
|
|
||||||
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
|
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
|
||||||
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2
|
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
|
||||||
|
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
|
||||||
|
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
|
||||||
|
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
|
||||||
|
filters_reason = "reason = CAST(:only_reason AS text)" in sql
|
||||||
|
only_reason = p.get("only_reason") if filters_reason else None
|
||||||
cleared = [
|
cleared = [
|
||||||
b
|
b
|
||||||
for b in self.bans
|
for b in self.bans
|
||||||
if b["proxy_id"] == p["proxy_id"]
|
if b["proxy_id"] == p["proxy_id"]
|
||||||
and (p["source"] is None or b["source"] == p["source"])
|
and (p["source"] is None or b["source"] == p["source"])
|
||||||
|
and (only_reason is None or b.get("reason") == only_reason)
|
||||||
]
|
]
|
||||||
self.bans = [b for b in self.bans if b not in cleared]
|
self.bans = [b for b in self.bans if b not in cleared]
|
||||||
return _FakeResult([{"source": b["source"]} for b in cleared])
|
return _FakeResult([{"source": b["source"]} for b in cleared])
|
||||||
|
|
|
||||||
|
|
@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red(
|
||||||
row = db._by_id(1)
|
row = db._by_id(1)
|
||||||
assert row["browser_unfit_since"] is not None
|
assert row["browser_unfit_since"] is not None
|
||||||
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
||||||
assert len(calls) == 2
|
# За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый
|
||||||
|
# транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за
|
||||||
|
# прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше.
|
||||||
|
assert len(calls) == 2 * 4 * 2
|
||||||
|
|
||||||
|
|
||||||
async def test_healthcheck_browser_probe_respects_slow_tick(
|
async def test_healthcheck_browser_probe_respects_slow_tick(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
|
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.
|
||||||
|
|
||||||
|
Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на
|
||||||
|
число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой
|
||||||
|
по-прежнему один на BROWSER_PROBE_MINUTES.
|
||||||
|
"""
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
_patch_probes(monkeypatch, calls=calls)
|
_patch_probes(monkeypatch, calls=calls)
|
||||||
db = FakeSession([_proxy(1)])
|
db = FakeSession([_proxy(1)])
|
||||||
|
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 1
|
assert len(calls) == 4
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
||||||
|
|
||||||
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
|
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
|
||||||
minutes=BROWSER_PROBE_MINUTES + 1
|
minutes=BROWSER_PROBE_MINUTES + 1
|
||||||
)
|
)
|
||||||
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
assert len(calls) == 2
|
assert len(calls) == 8
|
||||||
|
|
||||||
|
|
||||||
async def test_healthcheck_skips_browser_probe_when_http_dead(
|
async def test_healthcheck_skips_browser_probe_when_http_dead(
|
||||||
|
|
|
||||||
382
tradein-mvp/backend/tests/test_2800_per_source_probe.py
Normal file
382
tradein-mvp/backend/tests/test_2800_per_source_probe.py
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ.
|
||||||
|
|
||||||
|
Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь —
|
||||||
|
верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный
|
||||||
|
ответ означал «узел годен для Авито», а читался как «узел годен вообще».
|
||||||
|
|
||||||
|
Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация):
|
||||||
|
|
||||||
|
узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru
|
||||||
|
-----+----------+-------+---------------------+--------------+---------------------------
|
||||||
|
1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
|
||||||
|
10 | any | 200 | 200 | 200 | 200
|
||||||
|
|
||||||
|
Что сторожится (каждый тест ниже падает на коде до фикса):
|
||||||
|
|
||||||
|
1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для
|
||||||
|
Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')`
|
||||||
|
выдавал его как ни в чём не бывало.
|
||||||
|
2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен
|
||||||
|
(`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной).
|
||||||
|
3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех.
|
||||||
|
4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и
|
||||||
|
забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что
|
||||||
|
«дешёвая ipify стирает браузерный вердикт» в #2723).
|
||||||
|
5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой
|
||||||
|
вердикт #2723 сохранён).
|
||||||
|
6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс).
|
||||||
|
7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import scraper_kit.browser_fetcher as bf
|
||||||
|
|
||||||
|
from app.services import proxy_pool
|
||||||
|
from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release
|
||||||
|
from tests.services.test_proxy_pool import FakeSession, _proxy
|
||||||
|
|
||||||
|
# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным.
|
||||||
|
_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = {
|
||||||
|
("http://u:p@h1:8080", "domclick"): (
|
||||||
|
False,
|
||||||
|
"proxy",
|
||||||
|
'{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_probes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
matrix: dict[tuple[str, str], tuple[bool, str | None, str]],
|
||||||
|
calls: list[tuple[str, str]] | None = None,
|
||||||
|
*,
|
||||||
|
default: tuple[bool, str | None, str] = (True, None, "html_len=16477"),
|
||||||
|
) -> None:
|
||||||
|
"""ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник)."""
|
||||||
|
|
||||||
|
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||||
|
return True, "1.2.3.4", 10, None
|
||||||
|
|
||||||
|
async def _fake_browser(
|
||||||
|
endpoint: str, proxy_url: str, **kw: Any
|
||||||
|
) -> tuple[bool, str | None, str]:
|
||||||
|
source = str(kw.get("source", "avito"))
|
||||||
|
if calls is not None:
|
||||||
|
calls.append((proxy_url, source))
|
||||||
|
return matrix.get((proxy_url, source), default)
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||||||
|
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||||||
|
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. главное: вердикт разведён по источникам ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_node_dead_for_domclick_is_not_issued_to_domclick(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём.
|
||||||
|
|
||||||
|
Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при
|
||||||
|
`browser_fail_streak=0` и свежей пробе.
|
||||||
|
"""
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Домклику достаётся только исправный узел…
|
||||||
|
first = acquire(db, "domclick") # type: ignore[arg-type]
|
||||||
|
assert first is not None and first.id == 2
|
||||||
|
|
||||||
|
# …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить
|
||||||
|
# заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой
|
||||||
|
# заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0.
|
||||||
|
assert acquire(db, "domclick") is None, ( # type: ignore[arg-type]
|
||||||
|
"Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла.
|
||||||
|
lease = acquire(db, "avito") # type: ignore[arg-type]
|
||||||
|
assert lease is not None and lease.id == 1
|
||||||
|
|
||||||
|
ban = db._ban(1, "domclick")
|
||||||
|
assert ban is not None
|
||||||
|
assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба"
|
||||||
|
assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None
|
||||||
|
assert counters["pair_banned"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_dead_platform_does_not_stain_the_node_globally(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает."""
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
row = db._by_id(1)
|
||||||
|
assert row["browser_unfit_since"] is None
|
||||||
|
assert row["browser_fail_streak"] == 0
|
||||||
|
assert row["enabled"] is True and row["consecutive_fails"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел)."""
|
||||||
|
dead_everywhere = {
|
||||||
|
("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable")
|
||||||
|
for src in ("avito", "cian", "yandex", "domclick")
|
||||||
|
}
|
||||||
|
_patch_probes(monkeypatch, dead_everywhere)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
for _ in range(BROWSER_UNFIT_THRESHOLD):
|
||||||
|
db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is not None
|
||||||
|
assert db._by_id(2)["browser_unfit_since"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("source", "must_contain"),
|
||||||
|
[
|
||||||
|
("avito", "www.avito.ru"),
|
||||||
|
("cian", "ekb.cian.ru"),
|
||||||
|
("yandex", "realty.yandex.ru"),
|
||||||
|
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
|
||||||
|
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
|
||||||
|
("domclick", "bff-search-web.domclick.ru"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_probe_asks_the_working_host_of_each_source(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
|
||||||
|
) -> None:
|
||||||
|
seen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
text = '{"html": "<pre>User-agent: *</pre>"}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json() -> dict[str, str]:
|
||||||
|
return {"html": "<pre>User-agent: *</pre>"}
|
||||||
|
|
||||||
|
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
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
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=source
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert must_contain in seen["payload"]["url"]
|
||||||
|
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
|
||||||
|
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
|
||||||
|
assert seen["payload"]["source"] == "generic"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы."""
|
||||||
|
stub = "<!DOCTYPE html><html><head><title>Ошибка - Циан</title></head><body>…</body></html>"
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
text = "{}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json() -> dict[str, str]:
|
||||||
|
return {"html": stub}
|
||||||
|
|
||||||
|
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:
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
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="cian"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
|
||||||
|
assert fail_kind == "page"
|
||||||
|
assert "not robots.txt" in detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя."""
|
||||||
|
_patch_probes(
|
||||||
|
monkeypatch,
|
||||||
|
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
|
||||||
|
)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._ban(1, "cian") is not None
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. проба снимает только свою строку ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором.
|
||||||
|
|
||||||
|
robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить
|
||||||
|
дорогой вердикт значило бы повторить #2723 на паре.
|
||||||
|
"""
|
||||||
|
later = datetime.now(UTC) + timedelta(hours=6)
|
||||||
|
db = FakeSession(
|
||||||
|
[_proxy(1), _proxy(2)],
|
||||||
|
bans=[
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "avito",
|
||||||
|
"banned_until": later,
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "banned:avito", # распознан боевым сбором (капча/QRATOR)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"proxy_id": 1,
|
||||||
|
"source": "cian",
|
||||||
|
"banned_until": later,
|
||||||
|
"ban_count": 1,
|
||||||
|
"reason": "probe:browser", # прошлый вердикт самой пробы
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_patch_probes(monkeypatch, {}) # все площадки отвечают
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права"
|
||||||
|
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
|
||||||
|
assert counters["pair_cleared"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5-6. чужие отказы ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Лежащий сайдкар не должен забанить разом все пары (#2686-класс)."""
|
||||||
|
down = {
|
||||||
|
(f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError")
|
||||||
|
for pid in (1, 2)
|
||||||
|
for src in ("avito", "cian", "yandex", "domclick")
|
||||||
|
}
|
||||||
|
_patch_probes(monkeypatch, down)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["pair_banned"] == 0
|
||||||
|
assert db.bans == []
|
||||||
|
assert db._by_id(1)["browser_unfit_since"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. цена такта ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_cost_is_nodes_times_servable_sources(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Крест ограничен источниками, которым узел вообще может достаться.
|
||||||
|
|
||||||
|
Диагностика не должна превращаться в нагрузку: узел с выделенной affinity
|
||||||
|
спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника
|
||||||
|
= 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на
|
||||||
|
площадку в сутки против ~1000 боевых /fetch.
|
||||||
|
"""
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
_patch_probes(monkeypatch, {}, calls)
|
||||||
|
db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [
|
||||||
|
"avito",
|
||||||
|
"cian",
|
||||||
|
"domclick",
|
||||||
|
"yandex",
|
||||||
|
]
|
||||||
|
assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"]
|
||||||
|
assert len(calls) == 5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки.
|
||||||
|
|
||||||
|
Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из
|
||||||
|
теста выше не меняется, пока всё зелено.
|
||||||
|
"""
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
_patch_probes(monkeypatch, _LIVE_MATRIX, calls)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert calls.count(("http://u:p@h1:8080", "domclick")) == 2
|
||||||
|
assert calls.count(("http://u:p@h1:8080", "avito")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Один провал, второй заход зелёный → бан пары не пишется."""
|
||||||
|
attempts: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
|
||||||
|
return True, "1.2.3.4", 10, None
|
||||||
|
|
||||||
|
async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]:
|
||||||
|
source = str(kw.get("source", "avito"))
|
||||||
|
key = f"{proxy_url}|{source}"
|
||||||
|
attempts[key] = attempts.get(key, 0) + 1
|
||||||
|
if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1:
|
||||||
|
return False, "proxy", "503 browser unavailable"
|
||||||
|
return True, None, "html_len=150"
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
|
||||||
|
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
|
||||||
|
monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky)
|
||||||
|
db = FakeSession([_proxy(1), _proxy(2)])
|
||||||
|
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["pair_banned"] == 0
|
||||||
|
assert db._ban(1, "domclick") is None
|
||||||
|
lease = acquire(db, "domclick") # type: ignore[arg-type]
|
||||||
|
assert lease is not None
|
||||||
|
release(db, lease.id) # type: ignore[arg-type]
|
||||||
|
|
@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0
|
||||||
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
|
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
|
||||||
|
|
||||||
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
|
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
|
||||||
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
|
# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает:
|
||||||
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
|
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
|
||||||
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
|
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
|
||||||
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
|
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
|
||||||
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
|
# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge,
|
||||||
# и маршрут до её edge, а не до нейтрального хоста;
|
# а не до нейтрального хоста;
|
||||||
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
|
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который
|
||||||
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
|
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
|
||||||
# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16
|
#
|
||||||
# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08).
|
# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов
|
||||||
_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt"
|
# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал
|
||||||
# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу
|
# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1
|
||||||
# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок
|
# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея
|
||||||
# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот
|
# при этом browser_fail_streak=0 и свежую пробу.
|
||||||
# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые
|
#
|
||||||
# развёртки его не используют.
|
# ХОСТ — РАБОЧИЙ, А НЕ APEX-ДОМЕН. Тот же замер: через узел id=1
|
||||||
|
# `domclick.ru/robots.txt` отдаёт 200, а `bff-search-web.domclick.ru/robots.txt` —
|
||||||
|
# 500. Боевой сбор Домклика ходит именно на bff (providers/domclick/serp.py::
|
||||||
|
# _BFF_BASE), поэтому проба по apex была бы зелёной и бесполезной — та же ошибка
|
||||||
|
# «проба идёт не рабочим путём», что и #2723, на третьей оси.
|
||||||
|
_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",
|
||||||
|
}
|
||||||
|
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
|
||||||
|
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
|
||||||
|
# перебирает его, чтобы не заводить второй список площадок на стороне backend'а.
|
||||||
|
PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
|
||||||
|
|
||||||
|
# HTTP 200 + непустой HTML ещё не значит «площадка ответила»: замер 09.08 — Циан
|
||||||
|
# через узел id=1 отдаёт 200 и 374 КБ HTML со страницей «Ошибка — Циан» вместо
|
||||||
|
# robots.txt. Такой ответ проба до #2800 засчитывала как успех. Маркер ниже есть в
|
||||||
|
# robots.txt всех четырёх рабочих хостов (проверено вживую) и отсутствует в
|
||||||
|
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
|
||||||
|
# отказ с кодом 200».
|
||||||
|
_PROBE_CONTENT_MARKER: str = "User-agent"
|
||||||
|
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
|
||||||
|
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
|
||||||
|
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
|
||||||
|
# прокси сессии) — ровно тот relaunch-шторм, который лечил sticky-lease фикс.
|
||||||
|
# 'generic' — свой инстанс, боевые развёртки его не используют. Аргумент `source` у
|
||||||
|
# probe_proxy_via_browser выбирает АДРЕС (какую площадку спрашиваем), а не инстанс.
|
||||||
_PROXY_PROBE_SOURCE: str = "generic"
|
_PROXY_PROBE_SOURCE: str = "generic"
|
||||||
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
|
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
|
||||||
# замерено на проде вместе с релончем, плюс запас на медленный узел.
|
# замерено на проде вместе с релончем, плюс запас на медленный узел.
|
||||||
|
|
@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str:
|
||||||
browser_fail_streak.
|
browser_fail_streak.
|
||||||
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
|
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
|
||||||
503 «no proxy configured», прочие 5xx). Узел не виноват.
|
503 «no proxy configured», прочие 5xx). Узел не виноват.
|
||||||
- "page" — тракт сработал, но ответ не похож на страницу (пустое тело).
|
- "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое
|
||||||
Узел не виноват; повод посмотреть на площадку, не на пул.
|
тело либо 200 со страницей-заглушкой вместо robots.txt, #2800).
|
||||||
|
Браузерный тракт узла исправен — не годится ПАРА «узел ×
|
||||||
|
площадка»: этому exit-IP площадка ресурс не отдала.
|
||||||
"""
|
"""
|
||||||
if status is None:
|
if status is None:
|
||||||
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
|
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
|
||||||
|
|
@ -143,10 +173,11 @@ async def probe_proxy_via_browser(
|
||||||
proxy_url: str,
|
proxy_url: str,
|
||||||
*,
|
*,
|
||||||
proxy_kind: str = "http",
|
proxy_kind: str = "http",
|
||||||
url: str = _PROXY_PROBE_URL,
|
source: str = "avito",
|
||||||
|
url: str | None = None,
|
||||||
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
|
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
|
||||||
) -> tuple[bool, str | None, str]:
|
) -> tuple[bool, str | None, str]:
|
||||||
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
|
"""Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси.
|
||||||
|
|
||||||
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
|
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
|
||||||
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
|
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
|
||||||
|
|
@ -156,13 +187,18 @@ async def probe_proxy_via_browser(
|
||||||
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
|
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
|
||||||
нагрузка на неё, ради которой проба и затевалась бы наоборот.
|
нагрузка на неё, ради которой проба и затевалась бы наоборот.
|
||||||
|
|
||||||
|
`source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой
|
||||||
|
площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал
|
||||||
|
«узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся
|
||||||
|
для тестов и разовых проверок и перекрывает `source`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
|
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
|
||||||
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
|
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
|
||||||
обрезанный текст для лога.
|
обрезанный текст для лога.
|
||||||
"""
|
"""
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"url": url,
|
"url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL),
|
||||||
"source": _PROXY_PROBE_SOURCE,
|
"source": _PROXY_PROBE_SOURCE,
|
||||||
"proxy": proxy_url,
|
"proxy": proxy_url,
|
||||||
"proxy_kind": proxy_kind,
|
"proxy_kind": proxy_kind,
|
||||||
|
|
@ -184,6 +220,15 @@ async def probe_proxy_via_browser(
|
||||||
html = ""
|
html = ""
|
||||||
if not html:
|
if not html:
|
||||||
return False, classify_browser_probe(resp.status_code, detail), "empty 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 КБ «Ошибка — Циан»).
|
||||||
|
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"page",
|
||||||
|
f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
|
||||||
|
)
|
||||||
return True, None, f"html_len={len(html)}"
|
return True, None, f"html_len={len(html)}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue