fix(tradein/scraper): статус «забанен» перестаёт выдавать наш сбой за чужой (#2686) #2711
10 changed files with 486 additions and 14 deletions
|
|
@ -2239,6 +2239,10 @@ class UnifiedScrapeRunRow(BaseModel):
|
||||||
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
|
# не опрашивает (см. scrape_runs.honors_cancel): статус соврал бы, а
|
||||||
# has_running_run перестал бы держать single-run guard.
|
# has_running_run перестал бы держать single-run guard.
|
||||||
cancellable: bool = False
|
cancellable: bool = False
|
||||||
|
# #2686: диагноз для status='banned' — 'platform' (площадка заблокировала) или
|
||||||
|
# 'infra' (не отдал наш браузерный сайдкар). Без него оператор видит только
|
||||||
|
# «забанен» и делает вывод «площадка нас палит» на 80% наших же отказов.
|
||||||
|
ban_kind: str | None = None
|
||||||
params: dict | None = None
|
params: dict | None = None
|
||||||
counters: dict | None = None
|
counters: dict | None = None
|
||||||
total_seen: int | None = None
|
total_seen: int | None = None
|
||||||
|
|
@ -2346,6 +2350,7 @@ def list_scrape_runs_unified(
|
||||||
source=r["source"],
|
source=r["source"],
|
||||||
status=r["status"],
|
status=r["status"],
|
||||||
cancellable=runs_mod.honors_cancel(str(r["source"])),
|
cancellable=runs_mod.honors_cancel(str(r["source"])),
|
||||||
|
ban_kind=r.get("ban_kind"),
|
||||||
params=r.get("params"),
|
params=r.get("params"),
|
||||||
counters=r.get("counters"),
|
counters=r.get("counters"),
|
||||||
total_seen=r.get("total_seen"),
|
total_seen=r.get("total_seen"),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,22 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
|
||||||
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
||||||
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
||||||
|
|
||||||
|
# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218)
|
||||||
|
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
|
||||||
|
# статус» и «явное поле причины»:
|
||||||
|
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
|
||||||
|
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
|
||||||
|
# статус, пришлось бы дублировать её в каждом потребителе.
|
||||||
|
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
|
||||||
|
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
|
||||||
|
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
|
||||||
|
# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот
|
||||||
|
# класс оборванной проводки, из-за которого задача и появилась.
|
||||||
|
# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить
|
||||||
|
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
|
||||||
|
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
|
||||||
|
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
|
||||||
|
|
||||||
|
|
||||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||||
|
|
@ -353,11 +369,29 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
_alert_on_run_id(db, run_id)
|
_alert_on_run_id(db, run_id)
|
||||||
|
|
||||||
|
|
||||||
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
def mark_banned(
|
||||||
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, int],
|
||||||
|
*,
|
||||||
|
ban_kind: str = BAN_KIND_PLATFORM,
|
||||||
|
) -> None:
|
||||||
|
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
|
||||||
|
|
||||||
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
||||||
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
|
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
|
||||||
|
багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
|
||||||
|
теряет). Cooldown 2-4 часа.
|
||||||
|
|
||||||
|
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
|
||||||
|
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
|
||||||
|
- BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси).
|
||||||
|
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
|
||||||
|
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
|
||||||
|
вызывающие, которым разводить нечего, не меняются.
|
||||||
|
|
||||||
|
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
|
||||||
|
|
||||||
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
||||||
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
||||||
|
|
@ -373,6 +407,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
||||||
error = :error, counters = CAST(:counters AS jsonb),
|
error = :error, counters = CAST(:counters AS jsonb),
|
||||||
|
ban_kind = :ban_kind,
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
WHERE id = :run_id AND status = 'running'
|
WHERE id = :run_id AND status = 'running'
|
||||||
|
|
@ -383,6 +418,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"error": error[:1000],
|
"error": error[:1000],
|
||||||
"counters": json.dumps(counters),
|
"counters": json.dumps(counters),
|
||||||
|
"ban_kind": ban_kind,
|
||||||
"total_seen": total_seen,
|
"total_seen": total_seen,
|
||||||
"new_count": new_count,
|
"new_count": new_count,
|
||||||
},
|
},
|
||||||
|
|
@ -549,7 +585,7 @@ def list_all(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id AS run_id, source, status, params, counters,
|
SELECT id AS run_id, source, status, params, counters,
|
||||||
total_seen, new_count, started_at, finished_at,
|
ban_kind, total_seen, new_count, started_at, finished_at,
|
||||||
heartbeat_at, error AS error_text
|
heartbeat_at, error AS error_text
|
||||||
FROM scrape_runs
|
FROM scrape_runs
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
|
|
|
||||||
72
tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql
Normal file
72
tradein-mvp/backend/data/sql/218_scrape_runs_ban_kind.sql
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
-- 218_scrape_runs_ban_kind.sql
|
||||||
|
-- scrape_runs.ban_kind — статус 'banned' перестаёт смешивать наш сбой с чужим (#2686).
|
||||||
|
--
|
||||||
|
-- Dependencies: 015_scrape_runs.sql (создала таблицу и CHECK по status).
|
||||||
|
-- Apply after: 217_position_in_serp_unexpressible.sql
|
||||||
|
-- Идемпотентно: ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT + UPDATE только там,
|
||||||
|
-- где ban_kind ещё NULL.
|
||||||
|
--
|
||||||
|
-- ── ЧТО НАЙДЕНО ──────────────────────────────────────────────────────────────
|
||||||
|
-- Из 115 avito-прогонов со статусом 'banned' (замер на проде 2026-08-06):
|
||||||
|
-- 90 — «browser unavailable (proxy may be down)» — 503 НАШЕГО браузерного
|
||||||
|
-- сайдкара (05.07-03.08), площадка ни при чём;
|
||||||
|
-- 2 — прочие ошибки того же сайдкара (04.07, 15.07);
|
||||||
|
-- 10 — «Avito SERP firewall (browser-mode) … IP banned» — реальная блокировка
|
||||||
|
-- площадкой (16.06-06.08);
|
||||||
|
-- 11 — HTTP 429/403 (30.05-19.06);
|
||||||
|
-- 2 — прочее (21.06, 23.06).
|
||||||
|
-- То есть 92 из 115 (80%) прогонов, помеченных «нас забанили», — отказ нашей
|
||||||
|
-- собственной инфраструктуры. Оба исхода писали РАЗНЫЙ текст ошибки и получали
|
||||||
|
-- ОДИН статус: различитель лежал в данных и терялся ровно в момент присвоения.
|
||||||
|
--
|
||||||
|
-- ── ЦЕНА, КОТОРАЯ УЖЕ УПЛАЧЕНА ───────────────────────────────────────────────
|
||||||
|
-- Миграция 206 прочитала статус буквально — как «площадка распознаёт наш паттерн» —
|
||||||
|
-- и на этом основании замедлила avito_full_load_exhaustive более чем вдвое, переведя
|
||||||
|
-- на недельный такт. Основание было ложным. Возврат такта сюда НЕ входит: он вынесен
|
||||||
|
-- в #2687 на данные 9-10.08.
|
||||||
|
--
|
||||||
|
-- ── ПОЧЕМУ КОЛОНКА, А НЕ НОВЫЙ СТАТУС ────────────────────────────────────────
|
||||||
|
-- 1. У 'banned' есть побочная функция: в отличие от 'failed' он СОХРАНЯЕТ
|
||||||
|
-- done_buckets-чекпоинт пройденных бакетов. Она нужна обоим исходам — следующий
|
||||||
|
-- прогон не должен начинать с нуля ни при нашем отказе, ни при блокировке.
|
||||||
|
-- Оставив статус, получаем её даром.
|
||||||
|
-- 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
|
||||||
|
-- молча даёт неверный ответ, если про него забыть: этот CHECK, IN-списки обоих
|
||||||
|
-- сторожей в orchestration/runs.py, Literal-фильтр admin API и хардкод-список
|
||||||
|
-- статусов во фронте (RunsTable.tsx). Это ровно тот класс оборванной проводки,
|
||||||
|
-- из-за которого задача и появилась.
|
||||||
|
-- 3. Прогон в обоих случаях требует одного обращения (оборвать, сохранить частичное);
|
||||||
|
-- различается только ДИАГНОЗ — метаданное, не состояние.
|
||||||
|
--
|
||||||
|
-- В рантайме значение несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения
|
||||||
|
-- AvitoSidecarUnavailableError), а не разбирается из текста ошибки постфактум.
|
||||||
|
-- Разбор текста ниже — РАЗОВАЯ ретро-классификация уже накопленной истории; для
|
||||||
|
-- новых строк этот путь не используется.
|
||||||
|
|
||||||
|
ALTER TABLE scrape_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS ban_kind text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN scrape_runs.ban_kind IS
|
||||||
|
'Диагноз status=''banned'' (#2686): platform — площадка заблокировала '
|
||||||
|
'(firewall/403/captcha); infra — не отдала НАША инфраструктура (браузерный '
|
||||||
|
'сайдкар/прокси). NULL для прогонов с другим статусом. Пишется из типа '
|
||||||
|
'исключения в момент отказа, не из текста ошибки.';
|
||||||
|
|
||||||
|
ALTER TABLE scrape_runs DROP CONSTRAINT IF EXISTS scrape_runs_ban_kind_check;
|
||||||
|
ALTER TABLE scrape_runs
|
||||||
|
ADD CONSTRAINT scrape_runs_ban_kind_check
|
||||||
|
CHECK (ban_kind IS NULL OR ban_kind IN ('platform', 'infra'));
|
||||||
|
|
||||||
|
-- Ретро-классификация истории (разово, только там, где ещё NULL). Порядок веток
|
||||||
|
-- важен: маркер сайдкара проверяется первым, потому что текст полного обхода
|
||||||
|
-- оборачивает его в свой префикс («avito full load aborted: avito SERP
|
||||||
|
-- browser-sidecar error (page=1): browser unavailable (proxy may be down)»).
|
||||||
|
UPDATE scrape_runs
|
||||||
|
SET ban_kind = CASE
|
||||||
|
WHEN error ILIKE '%browser-sidecar error%'
|
||||||
|
OR error ILIKE '%browser unavailable%'
|
||||||
|
OR error ILIKE '%proxy may be down%' THEN 'infra'
|
||||||
|
ELSE 'platform'
|
||||||
|
END
|
||||||
|
WHERE status = 'banned'
|
||||||
|
AND ban_kind IS NULL;
|
||||||
246
tradein-mvp/backend/tests/test_2686_ban_kind_split.py
Normal file
246
tradein-mvp/backend/tests/test_2686_ban_kind_split.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""#2686: статус 'banned' перестаёт смешивать наш сбой с блокировкой площадкой.
|
||||||
|
|
||||||
|
Замер на проде (2026-08-06): из 115 avito-прогонов со статусом «забанен» 92 (80%) —
|
||||||
|
отказ НАШЕГО браузерного сайдкара («browser unavailable (proxy may be down)»), и
|
||||||
|
только 10 — реальный firewall площадки. По этому статусу приняли решение замедлить
|
||||||
|
avito_full_load_exhaustive более чем вдвое (миграция 206) — основание было ложным.
|
||||||
|
|
||||||
|
Различитель несётся от МЕСТА ПОРОЖДЕНИЯ отказа (тип исключения), а не разбирается
|
||||||
|
из текста ошибки постфактум. Проверяем три звена цепочки:
|
||||||
|
1. serp.py поднимает AvitoSidecarUnavailableError там и только там, где виноваты мы;
|
||||||
|
2. pipeline.ban_kind_of_exception переводит тип в диагноз;
|
||||||
|
3. run_avito_full_load доносит диагноз до mark_banned — и при ЭТОМ сохраняет
|
||||||
|
done_buckets-чекпоинт (побочная функция 'banned', которую нельзя потерять).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from scraper_kit.avito_exceptions import (
|
||||||
|
AvitoBlockedError,
|
||||||
|
AvitoRateLimitedError,
|
||||||
|
AvitoSidecarUnavailableError,
|
||||||
|
)
|
||||||
|
from scraper_kit.orchestration import runs as kit_runs
|
||||||
|
from scraper_kit.orchestration.pipeline import (
|
||||||
|
ban_kind_of_exception,
|
||||||
|
run_avito_full_load,
|
||||||
|
)
|
||||||
|
from scraper_kit.providers.avito import serp as serp_module
|
||||||
|
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||||
|
|
||||||
|
from app.services.scraper_adapters import RealScraperConfig
|
||||||
|
|
||||||
|
PFX = "scraper_kit.orchestration.pipeline"
|
||||||
|
|
||||||
|
# Настоящий firewall-интерстициал Авито: _is_firewall_page ловит его по тексту.
|
||||||
|
_FIREWALL_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_503() -> httpx.HTTPStatusError:
|
||||||
|
"""Ровно то, что прод писал 90 раз с 05.07 по 03.08."""
|
||||||
|
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
|
||||||
|
response = httpx.Response(
|
||||||
|
503, json={"error": "browser unavailable (proxy may be down)"}, request=request
|
||||||
|
)
|
||||||
|
return httpx.HTTPStatusError("503", request=request, response=response)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Место порождения ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sidecar_failure_raises_own_infra_subtype() -> None:
|
||||||
|
"""503 сайдкара → AvitoSidecarUnavailableError, и он ОСТАЁТСЯ AvitoRateLimitedError.
|
||||||
|
|
||||||
|
Фальсификация: до правки поднимался голый AvitoRateLimitedError — первый assert
|
||||||
|
падает. Второй assert стережёт совместимость: все существующие
|
||||||
|
`except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить
|
||||||
|
этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт.
|
||||||
|
"""
|
||||||
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
|
scraper._browser = AsyncMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503())
|
||||||
|
|
||||||
|
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
|
||||||
|
with pytest.raises(AvitoSidecarUnavailableError) as excinfo:
|
||||||
|
await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1)
|
||||||
|
|
||||||
|
assert isinstance(excinfo.value, AvitoRateLimitedError)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_platform_firewall_is_not_our_infra() -> None:
|
||||||
|
"""Рендер firewall-страницы → AvitoBlockedError, и это НЕ наш инфра-подтип."""
|
||||||
|
scraper = AvitoScraper(RealScraperConfig())
|
||||||
|
scraper._browser = AsyncMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(return_value=_FIREWALL_HTML)
|
||||||
|
scraper._browser.report_ban = MagicMock() # sync-хук, не корутина
|
||||||
|
|
||||||
|
with pytest.raises(AvitoBlockedError) as excinfo:
|
||||||
|
await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1)
|
||||||
|
|
||||||
|
assert not isinstance(excinfo.value, AvitoSidecarUnavailableError)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Перевод типа в диагноз ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exc", "expected"),
|
||||||
|
[
|
||||||
|
(AvitoSidecarUnavailableError("sidecar down"), kit_runs.BAN_KIND_INFRA),
|
||||||
|
(AvitoBlockedError("firewall"), kit_runs.BAN_KIND_PLATFORM),
|
||||||
|
(AvitoRateLimitedError("HTTP 429"), kit_runs.BAN_KIND_PLATFORM),
|
||||||
|
(RuntimeError("что угодно ещё"), kit_runs.BAN_KIND_PLATFORM),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ban_kind_of_exception(exc: BaseException, expected: str) -> None:
|
||||||
|
"""Честный 429 самой площадки остаётся 'platform' — инфра-диагноз даёт только подтип."""
|
||||||
|
assert ban_kind_of_exception(exc) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Диагноз доезжает до строки прогона, чекпоинт цел ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _BanRecorder:
|
||||||
|
"""Минимальный двойник scrape_runs: интересуют ban_kind и counters у mark_banned."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.banned: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.other: list[str] = []
|
||||||
|
|
||||||
|
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||||
|
self.other.append("mark_done")
|
||||||
|
|
||||||
|
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||||
|
self.other.append("mark_failed")
|
||||||
|
|
||||||
|
def mark_banned(
|
||||||
|
self,
|
||||||
|
db: Any,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, Any],
|
||||||
|
*,
|
||||||
|
ban_kind: str = kit_runs.BAN_KIND_PLATFORM,
|
||||||
|
) -> None:
|
||||||
|
self.banned.append((ban_kind, dict(counters)))
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
scraper_fetch_mode="curl_cffi",
|
||||||
|
browser_http_endpoint="http://browser.test/fetch",
|
||||||
|
scraper_proxy_url=None,
|
||||||
|
avito_proxy_max_rotations=0,
|
||||||
|
avito_serp_ok_not_banned=True,
|
||||||
|
avito_proxy_rotate_settle_s=0.0,
|
||||||
|
proxy_rotate_attempts=1,
|
||||||
|
proxy_rotate_attempt_timeout_s=1.0,
|
||||||
|
avito_full_load_price_cap=1400,
|
||||||
|
avito_full_load_max_pages=100,
|
||||||
|
avito_full_load_concurrency=1,
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
scraper_skip_seen_today=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scraper_that_saves_one_bucket_then(exc: Exception) -> MagicMock:
|
||||||
|
"""Fake AvitoScraper: один бакет успешно отдан в on_bucket, затем отказ."""
|
||||||
|
|
||||||
|
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
|
||||||
|
on_bucket("2к:0-5m", [MagicMock(source_id="a1")])
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
m = MagicMock()
|
||||||
|
m.__aenter__ = AsyncMock(return_value=m)
|
||||||
|
m.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
m.fetch_all_secondary = _fetch
|
||||||
|
m._browser = None
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def _drive_full_load(exc: Exception) -> _BanRecorder:
|
||||||
|
recorder = _BanRecorder()
|
||||||
|
with (
|
||||||
|
patch(f"{PFX}.AvitoScraper", return_value=_scraper_that_saves_one_bucket_then(exc)),
|
||||||
|
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
|
||||||
|
patch(f"{PFX}.runs", recorder),
|
||||||
|
):
|
||||||
|
await run_avito_full_load(MagicMock(), run_id=1, config=_config(), matcher=MagicMock())
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_load_sidecar_ban_is_infra_and_keeps_checkpoint() -> None:
|
||||||
|
"""Отказ нашего сайдкара → ban_kind='infra', done_buckets сохранён.
|
||||||
|
|
||||||
|
Фальсификация: до правки mark_banned не принимал ban_kind вовсе — вызов падал
|
||||||
|
бы на TypeError; с ban_kind, но без проброса из pipeline, диагноз был бы
|
||||||
|
'platform' (дефолт), то есть ровно то враньё, которое стоило миграции 206.
|
||||||
|
"""
|
||||||
|
recorder = await _drive_full_load(
|
||||||
|
AvitoSidecarUnavailableError("avito SERP browser-sidecar error (page=1)")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(recorder.banned) == 1
|
||||||
|
ban_kind, counters = recorder.banned[0]
|
||||||
|
assert ban_kind == kit_runs.BAN_KIND_INFRA
|
||||||
|
assert counters["done_buckets"] == ["2к:0-5m"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_load_platform_ban_keeps_the_same_checkpoint() -> None:
|
||||||
|
"""Блокировка площадкой → ban_kind='platform', чекпоинт сохраняется ТАК ЖЕ.
|
||||||
|
|
||||||
|
Второй исход не должен потерять побочную функцию 'banned' при разведении.
|
||||||
|
"""
|
||||||
|
recorder = await _drive_full_load(AvitoBlockedError("Avito SERP firewall — IP banned"))
|
||||||
|
|
||||||
|
assert len(recorder.banned) == 1
|
||||||
|
ban_kind, counters = recorder.banned[0]
|
||||||
|
assert ban_kind == kit_runs.BAN_KIND_PLATFORM
|
||||||
|
assert counters["done_buckets"] == ["2к:0-5m"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Диагноз доходит до SQL ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module_name", ["kit", "app"])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "expected"),
|
||||||
|
[({}, "platform"), ({"ban_kind": "infra"}, "infra")],
|
||||||
|
)
|
||||||
|
def test_mark_banned_writes_ban_kind(
|
||||||
|
module_name: str, kwargs: dict[str, str], expected: str
|
||||||
|
) -> None:
|
||||||
|
"""Обе копии runs-модуля кладут ban_kind в UPDATE (default — историческое 'platform')."""
|
||||||
|
if module_name == "kit":
|
||||||
|
mod: Any = kit_runs
|
||||||
|
else:
|
||||||
|
from app.services import scrape_runs as app_runs
|
||||||
|
|
||||||
|
mod = app_runs
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
mod.mark_banned(db, 1, "err", {"lots_fetched": 0}, **kwargs)
|
||||||
|
|
||||||
|
sql, params = db.execute.call_args_list[0][0] # первый UPDATE, не alert-запрос после
|
||||||
|
assert "ban_kind = :ban_kind" in str(sql)
|
||||||
|
assert params["ban_kind"] == expected
|
||||||
|
|
@ -43,6 +43,7 @@ class _RunsRecorder:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.ban_kinds: list[str] = []
|
||||||
|
|
||||||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
@ -53,7 +54,16 @@ class _RunsRecorder:
|
||||||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||||
self.calls.append(("mark_done", dict(counters)))
|
self.calls.append(("mark_done", dict(counters)))
|
||||||
|
|
||||||
def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
def mark_banned(
|
||||||
|
self,
|
||||||
|
db: Any,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, Any],
|
||||||
|
*,
|
||||||
|
ban_kind: str = "platform",
|
||||||
|
) -> None:
|
||||||
|
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
|
||||||
self.calls.append(("mark_banned", dict(counters)))
|
self.calls.append(("mark_banned", dict(counters)))
|
||||||
|
|
||||||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ class _RunsRecorder:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.ban_kinds: list[str] = []
|
||||||
|
|
||||||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
@ -62,7 +63,16 @@ class _RunsRecorder:
|
||||||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||||||
self.calls.append(("mark_done", dict(counters)))
|
self.calls.append(("mark_done", dict(counters)))
|
||||||
|
|
||||||
def mark_banned(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
def mark_banned(
|
||||||
|
self,
|
||||||
|
db: Any,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, Any],
|
||||||
|
*,
|
||||||
|
ban_kind: str = "platform",
|
||||||
|
) -> None:
|
||||||
|
self.ban_kinds.append(ban_kind) # #2686: диагноз, не статус
|
||||||
self.calls.append(("mark_banned", dict(counters)))
|
self.calls.append(("mark_banned", dict(counters)))
|
||||||
|
|
||||||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@ class AvitoRateLimitedError(AvitoError):
|
||||||
"""HTTP 429 от Avito — rate limit triggered."""
|
"""HTTP 429 от Avito — rate limit triggered."""
|
||||||
|
|
||||||
|
|
||||||
|
class AvitoSidecarUnavailableError(AvitoRateLimitedError):
|
||||||
|
"""НАШ браузерный сайдкар не отдал страницу — площадка ни при чём (#2686).
|
||||||
|
|
||||||
|
Поднимается только там, где отказ ПОРОЖДЁН нашей инфраструктурой: sidecar
|
||||||
|
вернул 503 «browser unavailable», упал внутри (Page.goto Timeout, браузер не
|
||||||
|
поднялся) либо соединение до него оборвалось (ReadTimeout/ConnectError). Это
|
||||||
|
отличается от AvitoBlockedError (площадка показала firewall) и от честного
|
||||||
|
HTTP 429 самой площадки, хотя все три раньше давали ОДИН статус 'banned' —
|
||||||
|
92 прогона из 114 (81%) за 45 суток были нашим сайдкаром, и по этому статусу
|
||||||
|
приняли решение замедлить сбор вдвое (миграция 206).
|
||||||
|
|
||||||
|
Наследует AvitoRateLimitedError НАМЕРЕННО: всё поведение прогона обязано
|
||||||
|
остаться прежним (`except (AvitoBlockedError, AvitoRateLimitedError)` в
|
||||||
|
pipeline/full-load/backfill ловят его без изменений → mark_banned →
|
||||||
|
done_buckets-чекпоинт сохраняется). Разводятся не пути, а ДИАГНОЗ:
|
||||||
|
pipeline пишет его в scrape_runs.ban_kind ('infra' vs 'platform').
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class AvitoListingGoneError(AvitoError):
|
class AvitoListingGoneError(AvitoError):
|
||||||
"""Объявление удалено / вернуло 404 (страница «Ошибка 404» вместо item-view).
|
"""Объявление удалено / вернуло 404 (страница «Ошибка 404» вместо item-view).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,17 @@ from urllib.parse import urlparse
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
from scraper_kit.avito_exceptions import (
|
||||||
|
AvitoBlockedError,
|
||||||
|
AvitoRateLimitedError,
|
||||||
|
AvitoSidecarUnavailableError,
|
||||||
|
)
|
||||||
from scraper_kit.base import ScrapedLot, save_listings
|
from scraper_kit.base import ScrapedLot, save_listings
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
from scraper_kit.orchestration import runs
|
from scraper_kit.orchestration import runs
|
||||||
|
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
|
||||||
|
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
|
||||||
|
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM
|
||||||
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
||||||
from scraper_kit.providers.avito.houses import (
|
from scraper_kit.providers.avito.houses import (
|
||||||
fetch_house_catalog,
|
fetch_house_catalog,
|
||||||
|
|
@ -184,6 +191,20 @@ _AVITO_DETAIL_CONSECUTIVE_TIMEOUT_ABORT: int = 5
|
||||||
# прерывают enrichment-фазу. Listings из SEARCH+SAVE сохраняются всегда.
|
# прерывают enrichment-фазу. Listings из SEARCH+SAVE сохраняются всегда.
|
||||||
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT: int = 3
|
_AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
def ban_kind_of_exception(exc: BaseException) -> str:
|
||||||
|
"""Диагноз для scrape_runs.ban_kind по ТИПУ исключения, не по тексту (#2686).
|
||||||
|
|
||||||
|
AvitoSidecarUnavailableError поднимается ровно там, где отказ породила наша
|
||||||
|
инфраструктура (браузерный сайдкар не отдал страницу) — всё остальное, что
|
||||||
|
доводит прогон до 'banned' (firewall, 403/captcha, честный 429 площадки, N
|
||||||
|
подряд заблокированных якорей), внешнее.
|
||||||
|
|
||||||
|
Один узел на все avito-сайты mark_banned: разводить исход в каждом из трёх
|
||||||
|
было бы тремя копиями одного условия.
|
||||||
|
"""
|
||||||
|
return BAN_KIND_INFRA if isinstance(exc, AvitoSidecarUnavailableError) else BAN_KIND_PLATFORM
|
||||||
|
|
||||||
# #2160: константы для расчёта watchdog-таймаута Cian city sweep. При
|
# #2160: константы для расчёта watchdog-таймаута Cian city sweep. При
|
||||||
# USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене
|
# USE_PROXY_POOL_BROWSER=true каждый SERP-фетч идёт через camoufox с relaunch при смене
|
||||||
# прокси (page.goto timeout 60s + overhead) = 13-45s/страница, а якорь cian = 4 room-buckets
|
# прокси (page.goto timeout 60s + overhead) = 13-45s/страница, а якорь cian = 4 room-buckets
|
||||||
|
|
@ -1544,7 +1565,13 @@ async def run_avito_city_sweep(
|
||||||
{**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type]
|
{**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
runs.mark_banned(db, run_id, str(e), counters.to_dict())
|
runs.mark_banned(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
str(e),
|
||||||
|
counters.to_dict(),
|
||||||
|
ban_kind=ban_kind_of_exception(e),
|
||||||
|
)
|
||||||
return counters
|
return counters
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("city-sweep run_id=%d: anchor %s failed", run_id, name)
|
logger.exception("city-sweep run_id=%d: anchor %s failed", run_id, name)
|
||||||
|
|
@ -1752,7 +1779,9 @@ async def run_avito_newbuilding_sweep(
|
||||||
)
|
)
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||||
logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e)
|
logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e)
|
||||||
runs.mark_banned(db, run_id, str(e), counters.to_dict())
|
runs.mark_banned(
|
||||||
|
db, run_id, str(e), counters.to_dict(), ban_kind=ban_kind_of_exception(e)
|
||||||
|
)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
counters.lots_fetched += len(lots)
|
counters.lots_fetched += len(lots)
|
||||||
|
|
@ -3616,7 +3645,11 @@ async def run_avito_full_load(
|
||||||
db,
|
db,
|
||||||
run_id,
|
run_id,
|
||||||
f"avito full load aborted: {exc}",
|
f"avito full load aborted: {exc}",
|
||||||
|
# done_buckets — чекпоинт пройденных бакетов; он сохраняется ОДИНАКОВО
|
||||||
|
# при обоих ban_kind (#2686 треб. 2): следующий прогон не начинает с нуля
|
||||||
|
# ни при нашем отказе сайдкара, ни при блокировке площадкой.
|
||||||
{**counters.to_dict(), "done_buckets": sorted(done)},
|
{**counters.to_dict(), "done_buckets": sorted(done)},
|
||||||
|
ban_kind=ban_kind_of_exception(exc),
|
||||||
)
|
)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,22 @@ CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3
|
||||||
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
# невидим для CONSECUTIVE_FAILURE_ALERT_THRESHOLD (тот считает только failed/banned).
|
||||||
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
CONSECUTIVE_ZERO_RESULT_ALERT_THRESHOLD = 3
|
||||||
|
|
||||||
|
# #2686: диагноз оборванного прогона. Пишется в scrape_runs.ban_kind (миграция 218)
|
||||||
|
# РЯДОМ со status='banned', а не ВМЕСТО него — сознательный выбор между «новый
|
||||||
|
# статус» и «явное поле причины»:
|
||||||
|
# 1. Побочная функция 'banned' — сохранение done_buckets-чекпоинта (mark_failed
|
||||||
|
# его теряет) — нужна ОБОИМ исходам. Оставив статус, получаем её даром; расщепив
|
||||||
|
# статус, пришлось бы дублировать её в каждом потребителе.
|
||||||
|
# 2. Новое значение статуса пришлось бы доучить пяти местам, каждое из которых
|
||||||
|
# молча даёт неверный ответ, если про него забыть: CHECK-констрейнт схемы,
|
||||||
|
# IN-списки обоих сторожей (_alert_if_consecutive_failures / _zero_results),
|
||||||
|
# Literal-фильтр admin API и хардкод-список статусов во фронте. Это ровно тот
|
||||||
|
# класс оборванной проводки, из-за которого задача и появилась.
|
||||||
|
# 3. Прогон в обоих случаях требует одного и того же обращения (оборвать, сохранить
|
||||||
|
# частичное); различается только ДИАГНОЗ — то есть метаданное, не состояние.
|
||||||
|
BAN_KIND_PLATFORM = "platform" # площадка показала firewall/403/captcha — внешнее
|
||||||
|
BAN_KIND_INFRA = "infra" # наш сайдкар/прокси не отдал страницу — внутреннее
|
||||||
|
|
||||||
|
|
||||||
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
|
||||||
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
"""Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.
|
||||||
|
|
@ -436,11 +452,29 @@ def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
_alert_on_run_id(db, run_id)
|
_alert_on_run_id(db, run_id)
|
||||||
|
|
||||||
|
|
||||||
def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
|
def mark_banned(
|
||||||
"""Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).
|
db: Session,
|
||||||
|
run_id: int,
|
||||||
|
error: str,
|
||||||
|
counters: dict[str, int],
|
||||||
|
*,
|
||||||
|
ban_kind: str = BAN_KIND_PLATFORM,
|
||||||
|
) -> None:
|
||||||
|
"""Финализация run: status='banned' + диагноз ban_kind (#2686).
|
||||||
|
|
||||||
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
|
||||||
Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.
|
Отличается от 'failed': прогон оборван внешним/блокирующим условием, а не нашим
|
||||||
|
багом, и — важно — СОХРАНЯЕТ done_buckets-чекпоинт в counters (mark_failed его
|
||||||
|
теряет). Cooldown 2-4 часа.
|
||||||
|
|
||||||
|
`ban_kind` разводит два исхода, которые раньше схлопывались в один статус:
|
||||||
|
- BAN_KIND_PLATFORM — площадка нас заблокировала (firewall/403/captcha);
|
||||||
|
- BAN_KIND_INFRA — упала НАША инфраструктура (браузерный сайдкар/прокси).
|
||||||
|
Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора
|
||||||
|
текста ошибки. Default 'platform' = историческая семантика статуса, поэтому
|
||||||
|
вызывающие, которым разводить нечего, не меняются.
|
||||||
|
|
||||||
|
Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом.
|
||||||
|
|
||||||
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
|
||||||
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
он мог оставить сессию в error state — rollback сбрасывает состояние.
|
||||||
|
|
@ -456,6 +490,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
UPDATE scrape_runs
|
UPDATE scrape_runs
|
||||||
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
|
||||||
error = :error, counters = CAST(:counters AS jsonb),
|
error = :error, counters = CAST(:counters AS jsonb),
|
||||||
|
ban_kind = :ban_kind,
|
||||||
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
|
||||||
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
new_count = COALESCE(CAST(:new_count AS int), new_count)
|
||||||
WHERE id = :run_id AND status = 'running'
|
WHERE id = :run_id AND status = 'running'
|
||||||
|
|
@ -466,6 +501,7 @@ def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int])
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"error": error[:1000],
|
"error": error[:1000],
|
||||||
"counters": json.dumps(counters),
|
"counters": json.dumps(counters),
|
||||||
|
"ban_kind": ban_kind,
|
||||||
"total_seen": total_seen,
|
"total_seen": total_seen,
|
||||||
"new_count": new_count,
|
"new_count": new_count,
|
||||||
},
|
},
|
||||||
|
|
@ -552,7 +588,7 @@ def list_all(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT id AS run_id, source, status, params, counters,
|
SELECT id AS run_id, source, status, params, counters,
|
||||||
total_seen, new_count, started_at, finished_at,
|
ban_kind, total_seen, new_count, started_at, finished_at,
|
||||||
heartbeat_at, error AS error_text
|
heartbeat_at, error AS error_text
|
||||||
FROM scrape_runs
|
FROM scrape_runs
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from scraper_kit.avito_exceptions import (
|
||||||
AvitoBlockedError,
|
AvitoBlockedError,
|
||||||
AvitoContentBlockedError,
|
AvitoContentBlockedError,
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
|
AvitoSidecarUnavailableError,
|
||||||
)
|
)
|
||||||
from scraper_kit.base import BaseScraper, ScrapedLot
|
from scraper_kit.base import BaseScraper, ScrapedLot
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||||
|
|
@ -586,7 +587,11 @@ class AvitoScraper(BaseScraper):
|
||||||
url,
|
url,
|
||||||
error_text[:200],
|
error_text[:200],
|
||||||
)
|
)
|
||||||
raise AvitoRateLimitedError(
|
# #2686: подтип AvitoRateLimitedError — поведение прогона прежнее
|
||||||
|
# (mark_banned + done_buckets-чекпоинт), но диагноз честный: это НАШ
|
||||||
|
# сайдкар, а не площадка. Признак несётся отсюда, с места порождения,
|
||||||
|
# а не разбирается из текста ошибки постфактум.
|
||||||
|
raise AvitoSidecarUnavailableError(
|
||||||
f"avito SERP browser-sidecar error (page={page}): {error_text[:200]}"
|
f"avito SERP browser-sidecar error (page={page}): {error_text[:200]}"
|
||||||
) from exc
|
) from exc
|
||||||
# Пустой рендер ("" / None) = таймаут рендера sidecar'а, НЕ firewall:
|
# Пустой рендер ("" / None) = таймаут рендера sidecar'а, НЕ firewall:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue