fix(tradein/proxy): проба спрашивает каждую площадку, вердикт пишется на пару «узел × источник» (#2800 B) #2803
5 changed files with 717 additions and 51 deletions
|
|
@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
|
|||
«Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой
|
||||
узел последним (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.
|
||||
"""
|
||||
|
||||
|
|
@ -125,6 +147,7 @@ __all__ = [
|
|||
"mark_banned",
|
||||
"mark_browser_health",
|
||||
"mark_health",
|
||||
"mark_source_probe",
|
||||
"reap_stale_leases",
|
||||
"release",
|
||||
"run_proxy_healthcheck",
|
||||
|
|
@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
|
|||
# намеренно не обновляется (см. mark_browser_health).
|
||||
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 для
|
||||
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
|
||||
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
|
||||
|
|
@ -675,9 +718,14 @@ def mark_browser_health(
|
|||
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.
|
||||
|
||||
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
|
||||
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы —
|
||||
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
|
||||
clear_source_bans умеет фильтровать по ней (`only_reason`).
|
||||
|
||||
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
|
||||
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
|
||||
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
|
||||
|
|
@ -813,7 +861,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
|
|||
{
|
||||
"proxy_id": proxy_id,
|
||||
"source": source,
|
||||
"reason": f"banned:{source}",
|
||||
"reason": reason or f"banned:{source}",
|
||||
"base_hours": SOURCE_BAN_BASE_HOURS,
|
||||
"max_hours": SOURCE_BAN_MAX_HOURS,
|
||||
"max_fails": MAX_CONSECUTIVE_FAILS,
|
||||
|
|
@ -859,7 +907,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 число снятых строк.
|
||||
|
||||
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
|
||||
|
|
@ -882,6 +937,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
SOURCE_BAN_BASE_HOURS.
|
||||
|
||||
`reason` идёт только в лог (человекочитаемый повод — «manual enable», «ip rotated»).
|
||||
|
||||
`only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
|
||||
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
|
||||
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
|
||||
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
|
||||
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
|
||||
целиком. None — снимать всё, как и раньше.
|
||||
"""
|
||||
rows = db.execute(
|
||||
text(
|
||||
|
|
@ -889,10 +951,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
DELETE FROM scrape_proxy_source_bans
|
||||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||||
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
|
||||
"""
|
||||
),
|
||||
{"proxy_id": proxy_id, "source": source},
|
||||
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
|
||||
).fetchall()
|
||||
db.commit()
|
||||
if rows:
|
||||
|
|
@ -906,6 +969,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
|
|||
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:
|
||||
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
|
||||
|
||||
|
|
@ -969,8 +1101,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
|||
return False, None, None, "other"
|
||||
|
||||
|
||||
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
|
||||
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
|
||||
def _probe_sources_for(affinity: str) -> list[str]:
|
||||
"""Источники, которым узел с такой 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: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
|
||||
healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не
|
||||
|
|
@ -978,18 +1141,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
|
||||
|
||||
try:
|
||||
ok, fail_kind, detail = await probe_proxy_via_browser(
|
||||
_settings.browser_http_endpoint, url, proxy_kind=kind
|
||||
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
|
||||
fail_kinds: list[str] = []
|
||||
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:
|
||||
logger.warning(
|
||||
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
|
||||
proxy_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return "ignored"
|
||||
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
|
||||
if outcome == "banned":
|
||||
counters["pair_banned"] += 1
|
||||
elif outcome == "cleared":
|
||||
counters["pair_cleared"] += 1
|
||||
|
||||
if counters["pair_checked"] == 0:
|
||||
return "ignored", counters # крест не состоялся — узел не судим
|
||||
|
||||
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:
|
||||
|
|
@ -1025,18 +1230,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
В конце — purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
|
||||
(см. комментарий у самого DELETE: отложенность — это и есть сброс ban_count).
|
||||
|
||||
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
|
||||
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
|
||||
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
|
||||
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
|
||||
никогда не смешивается с consecutive_fails/enabled. Гейт — settings.
|
||||
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
|
||||
измеряла бы то, чем никто не пользуется.
|
||||
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
|
||||
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
|
||||
САЙДКАР — по одной навигации на каждую площадку, которую этот узел может
|
||||
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
|
||||
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
|
||||
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные
|
||||
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт —
|
||||
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
|
||||
проба измеряла бы то, чем никто не пользуется.
|
||||
|
||||
Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на
|
||||
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
|
||||
{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)
|
||||
|
||||
|
|
@ -1044,7 +1251,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, url, kind, enabled, disabled_reason,
|
||||
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
|
||||
(browser_check_at IS NULL
|
||||
OR browser_check_at < now() - make_interval(
|
||||
mins => CAST(:browser_probe_minutes AS integer)
|
||||
|
|
@ -1075,6 +1282,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
browser_ok = 0
|
||||
browser_unfit = 0
|
||||
browser_refit = 0
|
||||
pair_checked = 0
|
||||
pair_banned = 0
|
||||
pair_cleared = 0
|
||||
for row in proxies:
|
||||
proxy_id = int(row["id"])
|
||||
url = str(row["url"])
|
||||
|
|
@ -1104,8 +1314,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
|
||||
# не тот узел», из-за которого #2723 и появилась.
|
||||
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
|
||||
pair_checked += pair_counters["pair_checked"]
|
||||
pair_banned += pair_counters["pair_banned"]
|
||||
pair_cleared += pair_counters["pair_cleared"]
|
||||
if outcome in ("ok", "refit"):
|
||||
browser_ok += 1
|
||||
if outcome == "refit":
|
||||
|
|
@ -1135,7 +1350,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
|
||||
logger.info(
|
||||
"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,
|
||||
checked,
|
||||
ok_count,
|
||||
|
|
@ -1146,6 +1362,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
browser_ok,
|
||||
browser_unfit,
|
||||
browser_refit,
|
||||
pair_checked,
|
||||
pair_banned,
|
||||
pair_cleared,
|
||||
)
|
||||
return {
|
||||
"reaped": reaped,
|
||||
|
|
@ -1160,4 +1379,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
|
|||
"browser_ok": browser_ok,
|
||||
"browser_unfit": browser_unfit,
|
||||
"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:
|
||||
# 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 = [
|
||||
b
|
||||
for b in self.bans
|
||||
if b["proxy_id"] == p["proxy_id"]
|
||||
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]
|
||||
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)
|
||||
assert row["browser_unfit_since"] is not None
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
|
||||
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.
|
||||
|
||||
Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на
|
||||
число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой
|
||||
по-прежнему один на BROWSER_PROBE_MINUTES.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
_patch_probes(monkeypatch, calls=calls)
|
||||
db = FakeSession([_proxy(1)])
|
||||
|
||||
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]
|
||||
assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
||||
assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox"
|
||||
|
||||
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
|
||||
minutes=BROWSER_PROBE_MINUTES + 1
|
||||
)
|
||||
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(
|
||||
|
|
|
|||
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 # навигация медленная → щедрый таймаут
|
||||
|
||||
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
|
||||
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
|
||||
# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает:
|
||||
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
|
||||
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
|
||||
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
|
||||
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
|
||||
# и маршрут до её edge, а не до нейтрального хоста;
|
||||
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
|
||||
# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge,
|
||||
# а не до нейтрального хоста;
|
||||
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который
|
||||
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
|
||||
# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16
|
||||
# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08).
|
||||
_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt"
|
||||
# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу
|
||||
# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок
|
||||
# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот
|
||||
# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые
|
||||
# развёртки его не используют.
|
||||
#
|
||||
# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов
|
||||
# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал
|
||||
# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1
|
||||
# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея
|
||||
# при этом browser_fail_streak=0 и свежую пробу.
|
||||
#
|
||||
# ХОСТ — РАБОЧИЙ, А НЕ 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"
|
||||
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
|
||||
# замерено на проде вместе с релончем, плюс запас на медленный узел.
|
||||
|
|
@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str:
|
|||
browser_fail_streak.
|
||||
- "sidecar" — сайдкар недоступен/не сконфигурирован (connect error, таймаут,
|
||||
503 «no proxy configured», прочие 5xx). Узел не виноват.
|
||||
- "page" — тракт сработал, но ответ не похож на страницу (пустое тело).
|
||||
Узел не виноват; повод посмотреть на площадку, не на пул.
|
||||
- "page" — тракт сработал, но ответ не похож на запрошенный ресурс (пустое
|
||||
тело либо 200 со страницей-заглушкой вместо robots.txt, #2800).
|
||||
Браузерный тракт узла исправен — не годится ПАРА «узел ×
|
||||
площадка»: этому exit-IP площадка ресурс не отдала.
|
||||
"""
|
||||
if status is None:
|
||||
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
|
||||
|
|
@ -143,10 +173,11 @@ async def probe_proxy_via_browser(
|
|||
proxy_url: str,
|
||||
*,
|
||||
proxy_kind: str = "http",
|
||||
url: str = _PROXY_PROBE_URL,
|
||||
source: str = "avito",
|
||||
url: str | None = None,
|
||||
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
|
||||
) -> tuple[bool, str | None, str]:
|
||||
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
|
||||
"""Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси.
|
||||
|
||||
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
|
||||
вредна — health-checker проверяет узлы, в том числе арендованные, и не должен
|
||||
|
|
@ -156,13 +187,18 @@ async def probe_proxy_via_browser(
|
|||
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки — это уже заметная
|
||||
нагрузка на неё, ради которой проба и затевалась бы наоборот.
|
||||
|
||||
`source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой
|
||||
площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал
|
||||
«узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся
|
||||
для тестов и разовых проверок и перекрывает `source`.
|
||||
|
||||
Returns:
|
||||
(ok, fail_kind, detail). ok=True → fail_kind=None. Иначе fail_kind —
|
||||
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail —
|
||||
обрезанный текст для лога.
|
||||
"""
|
||||
payload: dict[str, object] = {
|
||||
"url": url,
|
||||
"url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL),
|
||||
"source": _PROXY_PROBE_SOURCE,
|
||||
"proxy": proxy_url,
|
||||
"proxy_kind": proxy_kind,
|
||||
|
|
@ -184,6 +220,15 @@ 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 КБ «Ошибка — Циан»).
|
||||
# Тракт узла исправен, негодна пара — отсюда "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)}"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue