diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index d1f7fe7d..523dbf6c 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -149,7 +149,23 @@ async def _execute_cian_backfill( ) counters = {**_counters(result), "duration_sec": int(result.duration_sec)} - runs_mod.mark_done(db, run_id, counters) + # #3196: отказ detail-фетча теперь несёт диагноз (HTTP-статус последнего ответа + # сайдкара). В 'banned' переводим ТОЛЬКО прогон, который отказы видел и не + # обогатил НИЧЕГО, — частичный успех остаётся 'done', как и был. + if result.ban_kinds and (result.listings_succeeded + result.houses_succeeded) == 0: + counters["blocked"] = result.listings_blocked + # Полная перепись диагнозов, а не только доминирующий вид (#3196) — иначе + # запись прогона теряет, например, единичный infra среди platform. + counters["ban_kinds"] = dict(result.ban_kinds) + runs_mod.mark_banned( + db, + run_id, + f"cian detail: {result.listings_blocked} отказов, ни одного обогащения", + counters, + ban_kind=result.ban_kind, + ) + else: + runs_mod.mark_done(db, run_id, counters) logger.info( "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", run_id, diff --git a/tradein-mvp/backend/app/tasks/cian_history_backfill.py b/tradein-mvp/backend/app/tasks/cian_history_backfill.py index 2cb69680..3715f0a5 100644 --- a/tradein-mvp/backend/app/tasks/cian_history_backfill.py +++ b/tradein-mvp/backend/app/tasks/cian_history_backfill.py @@ -33,10 +33,11 @@ from __future__ import annotations import asyncio import logging import time +from collections import Counter from collections.abc import Callable from dataclasses import dataclass, field -from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment from scraper_kit.providers.cian.valuation import estimate_via_cian_valuation from sqlalchemy import text @@ -71,6 +72,42 @@ class CianBackfillResult: valuations_succeeded: int = 0 valuations_failed: int = 0 duration_sec: float = field(default=0.0) + # #3196: отказы detail-фетча и их перепись (kind -> сколько раз). До этой правки + # циановский прогон отдавал наверх только "не смогли обогатить", и scrape_runs.ban_kind + # у него не проставлялся вовсе. + listings_blocked: int = 0 + ban_kinds: Counter[str] = field(default_factory=Counter) + + @property + def ban_kind(self) -> str: + """Доминирующий диагноз отказов прогона (#3196), пригоден для scrape_runs.ban_kind. + + Считает тот же `_dominant_ban_kind`, что и остальные backfill-и: один вид → + он; строгое большинство → оно; иначе (и при пустой переписи) — 'unknown'. + Импорт локальный — таск не должен тянуть services на уровне модуля. + """ + from app.services.scrape_runs import _dominant_ban_kind + + return _dominant_ban_kind(self.ban_kinds) + + +def _note_refusal(result: CianBackfillResult, status: int | None) -> str | None: + """Записать отказ detail-фетча, если его природа диагностируема по HTTP-статусу (#3196). + + Инвариант: непустой `ban_kinds` ⟺ мы видели ответ с 403/429/5xx. `ban_kind_from_status` + отвечает диагнозом только на такие статусы; на всё прочее (в т.ч. статуса нет — сайдкар + не дошёл до навигации, или это HTTP 200 с промахом нашего парсера — дрейф разметки, а + не отказ площадки) возвращает None, и тогда эта функция НЕ инкрементит ни + `listings_blocked`, ни `ban_kinds` — только возвращает None вызывающему. Раньше + недиагностируемые случаи писались как 'unknown' и это в scrape_runs.mark_banned + (scheduler.py) превращало наши собственные сбои в фиктивный бан площадки. + """ + kind = ban_kind_from_status(status) + if kind is None: + return None + result.listings_blocked += 1 + result.ban_kinds[kind] += 1 + return kind async def backfill_cian_history( @@ -145,21 +182,29 @@ async def backfill_cian_history( try: enrichment = await fetch_detail(source_url, browser_fetcher=bf) except Exception as exc: + kind = _note_refusal(result, bf.last_response_status) logger.warning( - "cian_detail fetch failed for listing_id=%s url=%s: %s", + "cian_detail fetch failed for listing_id=%s url=%s: %s " + "(http=%s ban_kind=%s)", listing_id, source_url, exc, + bf.last_response_status, + kind, ) result.listings_failed_fetch += 1 await asyncio.sleep(delay) continue if enrichment is None: + kind = _note_refusal(result, bf.last_response_status) logger.warning( - "cian_detail fetch returned None for listing_id=%s url=%s", + "cian_detail fetch returned None for listing_id=%s url=%s " + "(http=%s ban_kind=%s)", listing_id, source_url, + bf.last_response_status, + kind, ) result.listings_failed_fetch += 1 await asyncio.sleep(delay) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index f4786ffa..84a8f85a 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -50,12 +50,15 @@ Exception triad differs from Avito: непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по длительности до блока 111-332 с, получает свой бюджет). - ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и - получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на - распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch - (наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два - случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у - avito), любой диагноз отсюда был бы назначенным, а не установленным. + ДИАГНОЗ (#3196, снимает ограничение #2764): scrape_runs.ban_kind теперь + передаётся. Один и тот же DomClickBlockedError по-прежнему поднимается и на + распознанном маркере, и на сбое браузерного fetch, но несёт HTTP-статус ответа + (`.status`, см. providers/domclick/detail.py::fetch_detail) — разводить типы + исключений ради этого не понадобилось. 403/429 → 'platform' (ровно тот случай, + что намеряли вживую 28.08: статическая страница `403 | Домклик` на 26 624 байта, + без PoW и капчи — это отказ, а не челлендж), 5xx → 'infra', статуса нет → + 'unknown', честно «не установлено». Раньше все 14 прогонов за 14 дней + получали 'unknown' просто потому, что диагноз не доезжал до финализатора. - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a block) -- counted as failed++, logged, does NOT touch consecutive_blocks and does NOT abort the run (neutral to the block-breaker, mirrors how Avito's @@ -84,10 +87,11 @@ import asyncio import logging import random import time +from collections import Counter from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment from sqlalchemy import text @@ -97,6 +101,7 @@ from app.core.config import settings from app.core.shutdown import shutdown_requested from app.services import domclick_session as domclick_session_svc from app.services import scrape_runs as runs_mod +from app.services.scrape_runs import BAN_KIND_UNKNOWN logger = logging.getLogger(__name__) @@ -159,6 +164,19 @@ def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None: ) +def _ban_kind_of_block(exc: DomClickBlockedError) -> str: + """Диагноз одного блока по HTTP-статусу ответа площадки (#3196). + + Форма повторяет avito_detail_backfill (ban_kind_of_exception → Counter → + mark_backfill_finished(ban_kinds=...)), но опора другая: у Авито причину несёт + ТИП исключения (AvitoSidecarUnavailableError vs AvitoBlockedError), у Домклика + тип один на оба случая, поэтому смотрим статус. 403/429 → 'platform', 5xx → + 'infra' (ban_kind_from_status), статуса нет → 'unknown' — не назначаем причину, + которую не установили (#2764). + """ + return ban_kind_from_status(getattr(exc, "status", None)) or BAN_KIND_UNKNOWN + + @dataclass class DomClickDetailBackfillResult: """Counters for one backfill run.""" @@ -267,6 +285,9 @@ async def run_domclick_detail_backfill( consecutive_blocks = 0 aborted_by_blocks = False do_sleep = False + # #3196: перепись диагнозов ВСЕХ блоков прогона (kind -> сколько раз); + # кратности важны — их разбирает _dominant_ban_kind в scrape_runs.py. + block_ban_kinds: Counter[str] = Counter() # Exactly ONE BrowserFetcher per run (no curl fallback for DomClick, see # module docstring). source="domclick" -- infra identifier, dedicated @@ -339,28 +360,32 @@ async def run_domclick_detail_backfill( except DomClickBlockedError as e: consecutive_blocks += 1 counters.blocked += 1 + ban_kind = _ban_kind_of_block(e) + block_ban_kinds[ban_kind] += 1 # #3118: сожжённый переиспользуемый context (протухший qrator_jsid2 # / стухшая сессия) не должен тянуться дальше по прогону — просим # ОДИН сброс на следующем fetch(), не на каждый последующий запрос. bf.request_context_reset() logger.warning( - "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", + "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d " + "(consecutive=%d, http=%s, kind=%s): %s", run_id, idx + 1, len(snapshot), consecutive_blocks, + getattr(e, "status", None), + ban_kind, e, ) if consecutive_blocks >= max_consecutive_blocks: - # #2764/#3178: DomClickBlockedError не разводит площадку (QRATOR) - # и наш браузерный тракт (см. докстринг класса выше) — причину - # НЕ выдумываем, пишем как есть. + # #3196: причину больше не выдумываем и не молчим — печатаем + # перепись диагнозов по HTTP-статусам этого прогона. logger.error( "domclick_detail_backfill: run_id=%d ABORT -- %d consecutive " - "blocks, причина не определена (площадка либо наш тракт). " - "enriched=%d attempted=%d", + "blocks, диагнозы: %s. enriched=%d attempted=%d", run_id, consecutive_blocks, + dict(block_ban_kinds) or "нет", counters.enriched, counters.attempted, ) @@ -392,6 +417,7 @@ async def run_domclick_detail_backfill( current_counters, source="domclick_detail_backfill", aborted_by_blocks=aborted_by_blocks, + ban_kinds=block_ban_kinds, ) logger.info( "domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 4ea7305e..49454c09 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -37,6 +37,13 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail: → parse always returns None → backfill would be 0% effective. The curl_cffi path (kit-профиль impersonation + mobile proxy) is already proven by yandex_address_backfill, which fetches identical offer detail pages. + +Природа блока (#3196): счётчика blocked у Яндекса не было вовсе, поэтому ветка +перевода прогона в 'banned' (scrape_runs.mark_backfill_finished) была недостижима +по построению — за 14 дней 12 done, 1 failed, 1 zombie и НОЛЬ банов. Теперь +non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса +(ban_kind_from_status: 403/429 → platform, 5xx → infra, прочее → unknown), а не +из текстовых маркеров страницы, снятых с чужой площадки. """ from __future__ import annotations @@ -44,9 +51,11 @@ from __future__ import annotations import asyncio import logging import time +from collections import Counter from dataclasses import dataclass, field from curl_cffi.requests import AsyncSession +from scraper_kit.browser_fetcher import ban_kind_from_status from scraper_kit.providers._base import DEFAULT_IMPERSONATE from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment from sqlalchemy import text @@ -54,6 +63,7 @@ from sqlalchemy.orm import Session from app.services import scrape_runs as runs_mod from app.services.proxy_egress import resolve_proxy_url +from app.services.scrape_runs import BAN_KIND_UNKNOWN logger = logging.getLogger(__name__) @@ -121,6 +131,11 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 + # Отказы площадки среди попыток (non-200 ответ) с разобранной природой. + # Подмножество failed, а не отдельная корзина: смысл failed («попытка не дала + # обогащения») не переписываем. Без этого счётчика ветка перевода прогона в + # 'banned' у Яндекса недостижима по построению (#3196). + blocked: int = 0 failed: int = 0 # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к @@ -134,6 +149,7 @@ class YandexDetailBackfillResult: return { "attempted": self.attempted, "enriched": self.enriched, + "blocked": self.blocked, "failed": self.failed, "url_from_offer_id": self.url_from_offer_id, "unenrichable_pending": self.unenrichable_pending, @@ -298,7 +314,17 @@ async def run_yandex_detail_backfill( _proxies = {"http": _proxy, "https": _proxy} if _proxy else None consecutive_none = 0 + # #3196: два счётчика серий — разной природы. consecutive_none — весь подряд + # идущий неуспех (фетч-ошибка, non-200, парс-None) и двигает только ABORT + # парс-None (наш дефект/дрейф разметки). consecutive_blocks — подряд идущие + # ТОЛЬКО non-200 ответы и двигает aborted_by_blocks/'banned': серию промахов + # парсера при HTTP 200 нельзя засчитывать как серию блоков площадки. + consecutive_blocks = 0 do_sleep = False + aborted_by_blocks = False + # Перепись диагнозов блоков (kind -> сколько раз). Кратности нужны целыми: + # доминирующий вид выбирает _dominant_ban_kind в scrape_runs.py (#3178). + block_ban_kinds: Counter[str] = Counter() scraper = YandexDetailScraper() async with AsyncSession( @@ -360,22 +386,33 @@ async def run_yandex_detail_backfill( continue if resp.status_code != 200: + # Природу отказа берём из HTTP-статуса, а не из текста + # страницы (#3196): 403/429 -> platform, 5xx -> infra. + # Статус, который о блоке ничего не сообщает, честно + # остаётся 'unknown' — это «не знаем, чей отказ», а не + # «отказа не было». + ban_kind = ban_kind_from_status(resp.status_code) or BAN_KIND_UNKNOWN + counters.blocked += 1 + block_ban_kinds[ban_kind] += 1 consecutive_none += 1 + consecutive_blocks += 1 counters.failed += 1 logger.warning( "yandex_detail_backfill: run_id=%d listing_id=%d " - "HTTP %d (consecutive=%d)", + "HTTP %d ban_kind=%s (consecutive=%d)", run_id, listing_id, resp.status_code, - consecutive_none, + ban_kind, + consecutive_blocks, ) - if consecutive_none >= max_consecutive_blocks: + if consecutive_blocks >= max_consecutive_blocks: + aborted_by_blocks = True logger.error( "yandex_detail_backfill: run_id=%d ABORT -- %d consecutive " "non-200 responses. enriched=%d attempted=%d", run_id, - consecutive_none, + consecutive_blocks, counters.enriched, counters.attempted, ) @@ -387,7 +424,15 @@ async def run_yandex_detail_backfill( if enrichment is None: # parse→None: captcha wall / shell-HTML / no JSON-LD. # Do not mark listing as done — retry next night. + # НАРОЧНО не считаем блоком (#3196): при HTTP 200 отличить + # капчу от промаха нашего парсера нечем, а записав это в + # blocked, мы объявляли бы 'banned' (внешняя причина) любой + # прогон, сломанный на нашей стороне, и потеряли бы сигнал + # 'failed', ради которого он заведён (#2674). consecutive_none += 1 + # Площадка ОТВЕТИЛА (HTTP 200) — серии подтверждённых блоков + # нет, что бы ни случилось дальше с парсингом (#3196). + consecutive_blocks = 0 counters.failed += 1 logger.warning( "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " @@ -410,6 +455,7 @@ async def run_yandex_detail_backfill( continue consecutive_none = 0 + consecutive_blocks = 0 if save_detail_enrichment(db, listing_id, enrichment): counters.enriched += 1 @@ -432,14 +478,20 @@ async def run_yandex_detail_backfill( counters.duration_sec = time.monotonic() - start current_counters = counters.to_dict() runs_mod.mark_backfill_finished( - db, run_id, current_counters, source="yandex_detail_backfill" + db, + run_id, + current_counters, + source="yandex_detail_backfill", + aborted_by_blocks=aborted_by_blocks, + ban_kinds=block_ban_kinds, ) logger.info( "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " - "failed=%d duration=%.1fs", + "blocked=%d failed=%d duration=%.1fs", run_id, counters.attempted, counters.enriched, + counters.blocked, counters.failed, counters.duration_sec, ) diff --git a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py index 50862dae..ff10c056 100644 --- a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py @@ -262,6 +262,10 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur (#2764: диагноз здесь НЕ установлен). До правки ABORT всегда писал 'QRATOR reputation likely burned for the session/proxy' -- утверждение, для которого нет основания в этом прогоне. + + #3196: причину теперь устанавливает HTTP-статус ответа. Здесь его нет (блок без + `.status`), поэтому лог печатает перепись диагнозов с честным 'unknown' -- и + по-прежнему НЕ называет QRATOR. """ snapshot = _make_snapshot(10) db = _mock_db(snapshot) @@ -289,7 +293,7 @@ async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptur abort_records = [r.message for r in caplog.records if "ABORT" in r.message] assert abort_records, "ожидался ABORT-лог" assert "QRATOR reputation likely burned" not in abort_records[0] - assert "причина не определена" in abort_records[0] + assert "диагнозы: {'unknown': 3}" in abort_records[0] @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_3196_cian_ban_kind.py b/tradein-mvp/backend/tests/test_3196_cian_ban_kind.py new file mode 100644 index 00000000..38849bcd --- /dev/null +++ b/tradein-mvp/backend/tests/test_3196_cian_ban_kind.py @@ -0,0 +1,224 @@ +"""#3196: циановский прогон обязан ставить диагноз отказа по HTTP-статусу. + +До правки браузерный путь `fetch_detail` глотал ЛЮБОЙ отказ: `BrowserFetcher.fetch` +возвращает str и на 403 не бросает, парсер не находил состояние и провайдер отдавал +None. Батч считал это `listings_failed_fetch`, планировщик звал `mark_done` — прогон +уходил в 'done' с пустым `scrape_runs.ban_kind`, и отличить «Циан нас отбил» от +«упал наш сайдкар» постфактум было нечем. + +Теперь отказ читается из `bf.last_response_status` (контракт фундамента #3196): +403/429 → 'platform', 5xx → 'infra', всё прочее → 'unknown'. В 'banned' переводится +только прогон, который отказы видел и не обогатил НИЧЕГО, — рабочий путь Циана +(13 done против 1 banned за 14 суток) не трогаем. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services import scheduler as sched_mod +from app.tasks import cian_history_backfill + + +class _FakeBrowserFetcher: + """Сайдкар, который отдаёт заданный HTTP-статус последнего ответа.""" + + def __init__(self, status: int | None = None, **kwargs: Any) -> None: + self.last_response_status = status + + async def __aenter__(self) -> _FakeBrowserFetcher: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +def _fetcher_factory(status: int | None): + def _make(**kwargs: Any) -> _FakeBrowserFetcher: + return _FakeBrowserFetcher(status) + + return _make + + +def _db_with_rows(n: int = 1) -> MagicMock: + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [ + {"id": i, "source_url": f"https://ekb.cian.ru/sale/flat/{i}/"} for i in range(1, n + 1) + ] + return db + + +async def _run(status: int | None, *, detail: Any, rows: int = 1): + """Один прогон listings-блока с заданным статусом и поведением fetch_detail.""" + with ( + patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(status)), + patch.object(cian_history_backfill, "fetch_detail", detail), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + return await cian_history_backfill.backfill_cian_history( + _db_with_rows(rows), do_listings=True, do_houses=False, do_valuations=False + ) + + +# ── Диагноз по статусу ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (403, "platform"), # WAF Циана отбил узел + (429, "platform"), # тот же класс: площадка отказала + (500, "infra"), # наш сайдкар/прокси не отдал страницу + (503, "infra"), + # Недиагностируемый ответ отказом НЕ считается (#3196): статуса нет — + # сайдкар не дошёл до навигации; HTTP 200 — площадка ответила, а пусто + # вернул наш парсер (дрейф разметки). Записав это в ban_kinds, мы бы + # объявляли фиктивный бан площадки на собственной поломке. + (None, None), + (200, None), + ], +) +async def test_ban_kind_from_http_status(status: int | None, expected: str | None) -> None: + result = await _run(status, detail=AsyncMock(return_value=None)) + + # Попытка провалилась в любом случае — этот счётчик от диагноза не зависит. + assert result.listings_failed_fetch == 1 + + if expected is None: + assert result.listings_blocked == 0 + assert result.ban_kinds == {} + else: + assert result.listings_blocked == 1 + assert result.ban_kinds == {expected: 1} + assert result.ban_kind == expected + + +async def test_fetch_exception_also_carries_diagnosis() -> None: + """Отказ, поднятый исключением (CianBlockedError на curl-пути), диагноз тоже несёт.""" + result = await _run(403, detail=AsyncMock(side_effect=Exception("HTTP 403 (WAF-блок узла)"))) + + assert result.listings_blocked == 1 + assert result.ban_kind == "platform" + + +async def test_mixed_census_without_majority_is_unknown() -> None: + """Ровно поровну platform/infra → честное 'unknown' (правило #3178).""" + result = cian_history_backfill.CianBackfillResult() + cian_history_backfill._note_refusal(result, 403) + cian_history_backfill._note_refusal(result, 503) + + assert result.ban_kinds == {"platform": 1, "infra": 1} + assert result.ban_kind == "unknown" + + +async def test_healthy_run_records_no_refusals() -> None: + """Рабочий путь: обогатили — переписи отказов нет, диагноз не навязывается.""" + with ( + patch.object(cian_history_backfill, "BrowserFetcher", _fetcher_factory(200)), + patch.object( + cian_history_backfill, + "fetch_detail", + AsyncMock(return_value=SimpleNamespace(price_changes=[])), + ), + patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + result = await cian_history_backfill.backfill_cian_history( + _db_with_rows(1), do_listings=True, do_houses=False, do_valuations=False + ) + + assert result.listings_succeeded == 1 + assert result.listings_blocked == 0 + assert result.ban_kinds == {} + + +# ── Диагноз доезжает до строки прогона ─────────────────────────────────────── + + +async def _execute(result: Any) -> SimpleNamespace: + fake_runs = SimpleNamespace( + update_heartbeat=MagicMock(), + mark_done=MagicMock(), + mark_banned=MagicMock(), + mark_failed=MagicMock(), + ) + + async def _batch(db: Any, **kwargs: Any) -> Any: + return result + + with ( + patch.object(sched_mod, "runs_mod", fake_runs), + patch.object(cian_history_backfill, "backfill_cian_history", _batch), + ): + await sched_mod._execute_cian_backfill(MagicMock(), run_id=7, params={}) + return fake_runs + + +@pytest.mark.parametrize( + ("status", "expected"), + [(403, "platform"), (503, "infra")], +) +async def test_run_row_gets_ban_kind(status: int | None, expected: str) -> None: + """Отказы + ноль обогащений → status='banned' с диагнозом из HTTP-статуса.""" + result = cian_history_backfill.CianBackfillResult() + result.listings_processed = 3 + for _ in range(3): + cian_history_backfill._note_refusal(result, status) + + runs = await _execute(result) + + assert not runs.mark_done.called, "прогон без единого обогащения не 'done'" + assert runs.mark_banned.called + assert runs.mark_banned.call_args.kwargs["ban_kind"] == expected + assert runs.mark_banned.call_args.args[3]["blocked"] == 3 + assert runs.mark_banned.call_args.args[3]["ban_kinds"] == {expected: 3} + + +async def test_parser_drift_is_not_a_ban() -> None: + """Ключевой инвариант #3196: HTTP 200 с пустым разбором — НЕ бан площадки. + + Три карточки подряд отдали 200, а enrichment не собрался — это дрейф разметки + Циана либо наш парсер. Прогон обязан остаться нашей поломкой (сюда сходится + ветка 'failed' у backfill-ей), а не превратиться в 'banned' с выдуманным + диагнозом: именно такие фиктивные 'unknown' и убирает #3196. + """ + result = cian_history_backfill.CianBackfillResult() + result.listings_processed = 3 + result.listings_failed_fetch = 3 + for _ in range(3): + assert cian_history_backfill._note_refusal(result, 200) is None + + runs = await _execute(result) + + assert result.ban_kinds == {} + assert result.listings_blocked == 0 + assert not runs.mark_banned.called, "промах парсера не объявляем отказом площадки" + assert runs.mark_done.called + + +async def test_partial_success_stays_done() -> None: + """Рабочий путь не трогаем: обогатили хоть что-то — 'done', даже при отказах.""" + result = cian_history_backfill.CianBackfillResult() + result.listings_processed = 2 + result.listings_succeeded = 1 + cian_history_backfill._note_refusal(result, 403) + + runs = await _execute(result) + + assert runs.mark_done.called + assert not runs.mark_banned.called + + +async def test_clean_empty_run_stays_done() -> None: + """Кандидатов не было — ни отказов, ни диагноза: 'done' как и раньше.""" + runs = await _execute(cian_history_backfill.CianBackfillResult()) + + assert runs.mark_done.called + assert not runs.mark_banned.called diff --git a/tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py b/tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py new file mode 100644 index 00000000..9cf520d8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3196_domclick_ban_kind.py @@ -0,0 +1,216 @@ +"""Честный ban_kind у domclick_detail_backfill по HTTP-статусу ответа (#3196). + +Проблема (прод, 14 дней): domclick_detail_backfill дал 14 банов и ВСЕ с +ban_kind='unknown'. Причин две и обе механические: + 1. задача вообще не передавала `ban_kinds` в mark_backfill_finished — диагноз не + доезжал до финализатора, каким бы он ни был; + 2. DomClickBlockedError поднимается и на распознанном маркере, и на любом сбое + браузерного фетча, поэтому по типу исключения их не развести. + +Фикс: исключение несёт `.status` (HTTP-код целевой навигации, см. +BrowserFetcher.last_response_status), задача раскладывает его через +ban_kind_from_status и копит перепись kind -> count — ровно как это делает +avito_detail_backfill (ban_kind_of_exception → Counter → mark_backfill_finished). + +403/429 → 'platform' (замер 28.08: статическая страница `403 | Домклик` на +26 624 байта, без PoW и капчи — отказ, а не челлендж), 5xx → 'infra', статуса нет +→ 'unknown' (не назначаем причину, которую не установили, #2764). + +Зеркалит конвенции tests/test_3118_domclick_warm_context.py (module-level +patch-target строки, _mock_db/_mock_session_svc helpers). +""" + +from __future__ import annotations + +import os +import sys +from collections import Counter +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 +from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 + +from app.tasks.domclick_detail_backfill import ( # noqa: E402 + _ban_kind_of_block, + run_domclick_detail_backfill, +) + +_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail" +_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment" +_RUNS = "app.tasks.domclick_detail_backfill.runs_mod" +_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep" +_SETTINGS = "app.tasks.domclick_detail_backfill.settings" +_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher" +_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc" + + +def _make_snapshot(n: int) -> list[dict]: + return [ + { + "id": i + 1, + "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}", + } + for i in range(n) + ] + + +def _mock_db(snapshot: list[dict]) -> MagicMock: + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + db.execute.return_value = sel + return db + + +def _mock_session_svc() -> MagicMock: + svc = MagicMock() + svc.load_session.return_value = {"CAS_ID": "123"} + svc.COOKIE_EXPIRY_WARN_DAYS = 5 + svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) + return svc + + +def _mock_browser_fetcher_cls() -> MagicMock: + instance = AsyncMock() + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + # синхронный метод — иначе остаётся неawaited coroutine + instance.request_context_reset = MagicMock() + return MagicMock(return_value=instance) + + +async def _run_all_blocked(status: int | None, *, blocks: int = 3) -> MagicMock: + """Прогон, где КАЖДЫЙ fetch — блок с данным HTTP-статусом. Возвращает runs-мок.""" + snapshot = _make_snapshot(blocks) + db = _mock_db(snapshot) + runs = MagicMock() + mock_fetch = AsyncMock(side_effect=DomClickBlockedError("domclick blocked", status=status)) + with ( + patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), + patch(_SESSION_SVC, _mock_session_svc()), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), + patch(_FETCH, mock_fetch), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + ): + await run_domclick_detail_backfill( + db, + run_id=3196, + params={"batch_size": blocks, "budget_sec": 3600, "max_consecutive_blocks": blocks}, + ) + return runs + + +def _ban_kinds_of(runs: MagicMock) -> Counter: + runs.mark_backfill_finished.assert_called_once() + return runs.mark_backfill_finished.call_args.kwargs["ban_kinds"] + + +# ── диагноз одного блока ───────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (403, "platform"), # ровно тот случай, что намеряли вживую: `403 | Домклик` + (429, "platform"), + (500, "infra"), + (502, "infra"), + (503, "infra"), + (200, "unknown"), # маркер поймали, но статус про блок ничего не говорит + (404, "unknown"), + (None, "unknown"), # сайдкар статус не отдал / фетч не состоялся + ], +) +def test_ban_kind_of_block(status: int | None, expected: str) -> None: + assert _ban_kind_of_block(DomClickBlockedError("boom", status=status)) == expected + + +def test_ban_kind_of_block_without_status_attr() -> None: + """Исключение без явного статуса → 'unknown', не падение.""" + assert _ban_kind_of_block(DomClickBlockedError("legacy")) == "unknown" + + +# ── прогон целиком: диагноз доезжает до финализатора ───────────────────────── + + +@pytest.mark.asyncio +async def test_run_403_gets_platform() -> None: + """403 (отказ площадки) → ban_kinds={'platform': 3} в mark_backfill_finished.""" + runs = await _run_all_blocked(403) + assert _ban_kinds_of(runs) == Counter({"platform": 3}) + assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True + + +@pytest.mark.asyncio +async def test_run_5xx_gets_infra() -> None: + """5xx (сбой площадки/прокси, не адресный бан) → 'infra'.""" + runs = await _run_all_blocked(503) + assert _ban_kinds_of(runs) == Counter({"infra": 3}) + + +@pytest.mark.asyncio +async def test_run_unrecognized_refusal_stays_unknown() -> None: + """Статуса нет → по-прежнему 'unknown': причину не выдумываем (#2764).""" + runs = await _run_all_blocked(None) + assert _ban_kinds_of(runs) == Counter({"unknown": 3}) + + +@pytest.mark.asyncio +async def test_run_mixed_statuses_keep_multiplicity() -> None: + """Перепись несёт КРАТНОСТИ — их разбирает _dominant_ban_kind, а не set (#3178).""" + snapshot = _make_snapshot(3) + db = _mock_db(snapshot) + runs = MagicMock() + mock_fetch = AsyncMock( + side_effect=[ + DomClickBlockedError("refused", status=403), + DomClickBlockedError("refused", status=429), + DomClickBlockedError("upstream", status=502), + ] + ) + with ( + patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), + patch(_SESSION_SVC, _mock_session_svc()), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), + patch(_FETCH, mock_fetch), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + ): + await run_domclick_detail_backfill( + db, + run_id=3196, + params={"batch_size": 3, "budget_sec": 3600, "max_consecutive_blocks": 3}, + ) + + assert _ban_kinds_of(runs) == Counter({"platform": 2, "infra": 1}) + + +@pytest.mark.asyncio +async def test_clean_run_passes_empty_ban_kinds() -> None: + """Без блоков перепись пуста — финализатор не должен видеть выдуманный диагноз.""" + snapshot = _make_snapshot(2) + db = _mock_db(snapshot) + runs = MagicMock() + with ( + patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), + patch(_SESSION_SVC, _mock_session_svc()), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), + patch(_FETCH, AsyncMock(return_value=MagicMock())), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + ): + await run_domclick_detail_backfill( + db, run_id=3196, params={"batch_size": 2, "budget_sec": 3600} + ) + + assert _ban_kinds_of(runs) == Counter() diff --git a/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py b/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py new file mode 100644 index 00000000..00e6e0cf --- /dev/null +++ b/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py @@ -0,0 +1,186 @@ +"""#3196: у yandex_detail_backfill не было счётчика blocked — бан был невозможен. + +`mark_backfill_finished` переводит прогон в 'banned' только при +`counters["blocked"] > 0`. Яндексовый detail-backfill этот счётчик не вёл вовсе, +поэтому ветка (`scrape_runs.py`, `if blocked and (aborted_by_blocks or produced == 0)`) +была недостижима ПО ПОСТРОЕНИЮ: прод за 14 дней — 12 done, 1 failed, 1 zombie и +НОЛЬ банов, при том что 403/429 от площадки прогоны видели. + +Диагноз теперь берётся из HTTP-статуса ответа, а не из текстовых маркеров +страницы (маркеры сняты с чужой площадки и на домкликовском отказе не сработали — +исходный симптом эпика): 403/429 → platform, 5xx → infra, статус, который о блоке +ничего не сообщает → 'unknown' (честное «не знаем», а не «блока не было»). + +Тесты гоняют НАСТОЯЩИЙ `mark_backfill_finished` — иначе проверялась бы только +передача аргумента, а не итоговый `scrape_runs.ban_kind`. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import scrape_runs as real_runs +from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill + +_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" +_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" +_RUNS = "app.tasks.yandex_detail_backfill.runs_mod" +_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" +_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" + + +class _Recorder: + """Подменяет runs_mod, но финализацию отдаёт настоящей mark_backfill_finished. + + Так тест видит именно тот исход, который получит строка scrape_runs: статус и + ban_kind считает продовый код, а не сам тест. + """ + + def __init__(self) -> None: + self.status: str | None = None + self.ban_kind: str | None = None + self.counters: dict[str, Any] = {} + + def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + return None + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self.status = "done" + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self.status = "failed" + + def mark_banned( + self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any + ) -> None: + self.status = "banned" + self.ban_kind = kw.get("ban_kind") + + def mark_backfill_finished( + self, db: Any, run_id: int, counters: dict[str, Any], **kw: Any + ) -> None: + self.counters = counters + with ( + patch.object(real_runs, "mark_done", self.mark_done), + patch.object(real_runs, "mark_failed", self.mark_failed), + patch.object(real_runs, "mark_banned", self.mark_banned), + ): + real_runs.mark_backfill_finished(db, run_id, counters, **kw) + + +def _mock_db(n: int) -> MagicMock: + snapshot = [ + {"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n) + ] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0) + db.execute.return_value = sel + return db + + +def _resp(status: int) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.text = "отказ" + return resp + + +def _session_cls(responses: list[MagicMock]) -> MagicMock: + session = AsyncMock() + session.get = AsyncMock(side_effect=responses) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=ctx) + + +async def _drive(status: int, *, count: int = 5, parse_result: Any = None) -> _Recorder: + """Прогон из `count` одинаковых ответов со статусом `status`.""" + recorder = _Recorder() + db = _mock_db(count) + with ( + patch(_ASYNC_SESSION, _session_cls([_resp(status)] * count)), + patch(_PARSE, return_value=parse_result), + patch(_RUNS, recorder), + patch(_SLEEP, new_callable=AsyncMock), + patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), + ): + await run_yandex_detail_backfill( + db, + run_id=3196, + params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": count}, + ) + return recorder + + +@pytest.mark.asyncio +async def test_http_403_marks_platform_ban() -> None: + """403 — осознанный отказ площадки. До правки: 0 банов, blocked не считался.""" + rec = await _drive(403) + assert rec.status == "banned", rec.status + assert rec.ban_kind == "platform" + assert rec.counters["blocked"] == 5 + assert rec.counters["ban_kinds"] == {"platform": 5} + + +@pytest.mark.asyncio +async def test_http_429_marks_platform_ban() -> None: + """429 (rate-limit) — та же природа, что 403: отказала площадка.""" + rec = await _drive(429) + assert rec.status == "banned" + assert rec.ban_kind == "platform" + + +@pytest.mark.asyncio +async def test_http_5xx_marks_infra_ban() -> None: + """5xx — сбой тракта/площадки, не адресный бан: 'infra', не 'platform'.""" + rec = await _drive(503) + assert rec.status == "banned" + assert rec.ban_kind == "infra" + assert rec.counters["ban_kinds"] == {"infra": 5} + + +@pytest.mark.asyncio +async def test_unrecognized_refusal_marks_unknown_ban() -> None: + """Отказ есть, а статус о его природе молчит → 'unknown', но прогон банится. + + Ровно случай эпика: страница-отказ, которую наши маркеры не опознают. Молчание + статуса не должно превращаться в «блока не было». + """ + rec = await _drive(418) + assert rec.status == "banned" + assert rec.ban_kind == "unknown" + assert rec.counters["blocked"] == 5 + + +@pytest.mark.asyncio +async def test_parse_none_at_200_is_not_a_ban() -> None: + """Контроль: HTTP 200 + parse→None — по-прежнему 'failed', а не 'banned'. + + При 200 отличить капчу от промаха НАШЕГО парсера нечем. Считать это блоком + значило бы называть внешней причиной каждый прогон, сломанный у нас, и потерять + сигнал 'failed' из #2674. + """ + rec = await _drive(200, parse_result=None) + assert rec.status == "failed", rec.status + assert rec.ban_kind is None + assert rec.counters["blocked"] == 0 + + +@pytest.mark.asyncio +async def test_successful_run_stays_done() -> None: + """Контроль: обогащение идёт — прогон 'done', банов нет.""" + with patch("app.tasks.yandex_detail_backfill.save_detail_enrichment", return_value=True): + rec = await _drive(200, parse_result=MagicMock()) + assert rec.status == "done", rec.status + assert rec.ban_kind is None + assert rec.counters["blocked"] == 0 diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py new file mode 100644 index 00000000..d24b70cb --- /dev/null +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py @@ -0,0 +1,160 @@ +"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196). + +Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой +навигации рядом с html: ``{"html": ..., "status": }``. Kit выносит его на +инстанс фетчера — АТРИБУТОМ, а не возвратом ``fetch()``: поток управления менять +нельзя, ``fetch()`` по-прежнему отдаёт ``str`` и по-прежнему не бросает там, где не +бросал раньше. + +Зачем: ДомКлик отдаёт статическую страницу «403 | Домклик» на 26 624 байта, где нет +ни startpow, ни qrator, ни капчи — ни один текстовый маркер сайдкара (все сняты с +Авито) на неё не срабатывает, и отказ уезжал наверх как валидный контент. 14 прогонов +domclick_detail_backfill подряд получили ban_kind=unknown ровно поэтому. + +Инварианты: + - status из тела → last_response_status (int) на КАЖДЫЙ успешный fetch; + - ключа "status" нет (сайдкар старой версии) ИЛИ он null → None, БЕЗ исключения; + - status нечислового типа → None (мусор в теле не должен ронять фетч); + - fetch упал → last_response_status сброшен в None (не отдаём статус прошлого); + - ban_kind_from_status раскладывает код в значение, допустимое CHECK-ограничением + scrape_runs.ban_kind ("platform" | "infra" | "unknown" | NULL). + +httpx полностью замокан (зеркалит test_kit_browser_fetcher_proxy_pool.py). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status + + +def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock: + """httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json.""" + resp = MagicMock() + if raise_exc is not None: + resp.raise_for_status.side_effect = raise_exc + else: + resp.raise_for_status.return_value = None + resp.json.return_value = json_payload + client = MagicMock() + client.post = AsyncMock(return_value=resp) + client.aclose = AsyncMock(return_value=None) + return client + + +async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher: + """Реально входит в `__aenter__`, потом подменяет httpx-клиент.""" + bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs) + await bf.__aenter__() + bf._client = client + return bf + + +# ── last_response_status ────────────────────────────────────────────────────── + + +async def test_status_starts_as_none() -> None: + """До первого fetch статуса нет — атрибут существует и равен None.""" + client = _mock_client({"html": "", "status": 200}) + bf = await _fetcher(client, source="domclick") + + assert bf.last_response_status is None + + +async def test_status_from_body_is_exposed() -> None: + client = _mock_client({"html": "<403 page>", "status": 403}) + bf = await _fetcher(client, source="domclick") + + html = await bf.fetch("https://domclick.ru/card/1") + + assert html == "<403 page>" # поток управления не изменился — fetch отдаёт str + assert bf.last_response_status == 403 + + +async def test_status_updated_on_every_fetch() -> None: + """Атрибут обновляется КАЖДЫМ _post_fetch, а не только первым.""" + client = _mock_client({"html": "", "status": 200}) + bf = await _fetcher(client, source="domclick") + + await bf.fetch("https://domclick.ru/1") + assert bf.last_response_status == 200 + + client.post.return_value.json.return_value = {"html": "<403>", "status": 403} + await bf.fetch("https://domclick.ru/2") + assert bf.last_response_status == 403 + + +async def test_missing_status_key_is_none_and_does_not_raise() -> None: + """Сайдкар старой версии (тело без "status") — фетч проходит, статуса просто нет.""" + client = _mock_client({"html": ""}) + bf = await _fetcher(client, source="avito") + + html = await bf.fetch("https://avito.ru/x") + + assert html == "" + assert bf.last_response_status is None + + +async def test_null_status_is_none() -> None: + """goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null.""" + client = _mock_client({"html": "", "status": None}) + bf = await _fetcher(client, source="avito") + + await bf.fetch("https://avito.ru/x") + + assert bf.last_response_status is None + + +async def test_non_int_status_is_ignored() -> None: + """Мусор в поле status не должен ронять фетч — читается как «статуса нет».""" + client = _mock_client({"html": "", "status": "403"}) + bf = await _fetcher(client, source="avito") + + await bf.fetch("https://avito.ru/x") + + assert bf.last_response_status is None + + +async def test_status_reset_on_failed_fetch() -> None: + """Фетч упал — не отдаём статус ПРОШЛОГО запроса.""" + client = _mock_client({"html": "", "status": 200}) + bf = await _fetcher(client, source="avito") + await bf.fetch("https://avito.ru/1") + assert bf.last_response_status == 200 + + client.post.side_effect = RuntimeError("transport down") + with pytest.raises(RuntimeError): + await bf.fetch("https://avito.ru/2") + + assert bf.last_response_status is None + + +# ── ban_kind_from_status ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (403, "platform"), + (429, "platform"), + (500, "infra"), + (502, "infra"), + (599, "infra"), + (200, None), + (301, None), + (404, None), + (None, None), + ], +) +def test_ban_kind_from_status(status: int | None, expected: str | None) -> None: + assert ban_kind_from_status(status) == expected + + +def test_ban_kind_values_fit_scrape_runs_check() -> None: + """Возврат обязан быть пригоден для scrape_runs.ban_kind как есть.""" + allowed = {"platform", "infra", "unknown", None} + for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600): + assert ban_kind_from_status(status) in allowed diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index fff97c2a..5fffb9d0 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -5,7 +5,8 @@ экспонирует простой HTTP API на базе aiohttp: GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} - POST /fetch → {"url","origin"(опц.),"cookies"(опц.)} → {"html": "..."} + POST /fetch → {"url","origin"(опц.),"cookies"(опц.)} → {"html": "...", + "status": } # status — HTTP-код целевой навигации POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"} POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} @@ -384,6 +385,10 @@ _page_counters: dict[str, int] = {} # provider → страниц с мом _locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу) _retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача _last_goto_at: dict[str, float] = {} # provider → loop-time последнего page.goto (пейсинг) +# provider → HTTP-статус ответа ПОСЛЕДНЕЙ целевой навигации (#3196). Пишется в +# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой +# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет. +_last_response_status: dict[str, int | None] = {} # #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический # из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, @@ -765,7 +770,7 @@ async def health_handler(request: web.Request) -> web.Response: async def fetch_handler(request: web.Request) -> web.Response: - """POST /fetch {"url": "..."} → {"html": "..."} + """POST /fetch {"url": "..."} → {"html": "...", "status": } Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы. Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся @@ -847,6 +852,9 @@ async def fetch_handler(request: web.Request) -> web.Response: reuse_context=reuse_context, reset_context=reset_context, ) + # Читаем статус под тем же локом, что и сам фетч, — иначе следующий + # запрос этого провайдера успел бы его перезаписать. + status = _last_response_status.get(provider) except Exception as exc: logger.error( "tradein-browser[%s]: fetch error url=%r: %s: %s", @@ -857,7 +865,9 @@ async def fetch_handler(request: web.Request) -> web.Response: ) return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500) - return web.json_response({"html": html}) + # Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий + # только его, ничего не заметит. "status" может быть null (goto вернул None). + return web.json_response({"html": html, "status": status}) async def fetch_json_handler(request: web.Request) -> web.Response: @@ -1070,6 +1080,28 @@ _CHALLENGE_MARKERS: tuple[str, ...] = ( _BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",) +# HTTP-статусы, которые означают отказ площадки, а не временную проверку. Домклик +# отдаёт ровно 403 со статической страницей на 26 624 байта, где нет НИ одного +# маркера из _CHALLENGE_MARKERS/_BAN_MARKERS (оба сняты с Авито) — замер прода +# 28.08.2026, #3196. Такой ответ уезжал наверх как валидный HTML, парсер не находил +# состояние страницы, и прогон получал блок неизвестной природы (ban_kind=unknown). +# Статус — ДОПОЛНИТЕЛЬНЫЙ сигнал: ветки _is_ban_page/_is_pow_challenge остаются, они +# ловят отказ, отданный с кодом 200 (так делает Авито). +_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429}) + + +def _status_of(response: object) -> int | None: + """HTTP-статус playwright Response; None, если ответа нет или статус нечитаем. + + ``page.goto`` штатно возвращает None (редирект на тот же документ, навигационная + гонка) — это не ошибка, просто статус неизвестен. + """ + if response is None: + return None + status = getattr(response, "status", None) + return status if isinstance(status, int) else None + + class ChallengeTimeoutError(Exception): """PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS. @@ -1258,6 +1290,10 @@ async def _fetch_once( browser = _browsers.get(provider) assert browser is not None, "browser not launched" + # Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен + # уехать статус предыдущей страницы этого же провайдера (#3196). + _last_response_status[provider] = None + if reset_context: await _close_reusable_context(provider) @@ -1282,7 +1318,12 @@ async def _fetch_once( origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] - await page.goto(url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] + # Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует + # ответ площадки на запрошенный url (#3196). + response = await page.goto( # type: ignore[attr-defined] + url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" + ) + _last_response_status[provider] = _status_of(response) if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] html: str = await page.content() # type: ignore[attr-defined] @@ -1296,6 +1337,19 @@ async def _fetch_once( # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. if _is_pow_challenge(html): + status = _last_response_status.get(provider) + if status in _REFUSAL_STATUSES: + # Маркеры челленджа при 403/429 — это отказ, а не проверка: + # статическая страница сама себя не перезагрузит, ждать нечего. + # Наверх идёт ИСКЛЮЧЕНИЕ, а не заглушка: вернув её как валидный + # контент, мы бы воскресили регрессию #3045 — авитовская бан-страница + # приходит ровно с 403/429 (см. _BAN_MARKERS выше), и парсер получил + # бы 7891-байтный челлендж вместо карточки. Клиент уже трактует + # BanPageDetectedError как блок. + raise BanPageDetectedError( + f"tradein-browser[{provider}]: HTTP {status} + маркеры челленджа " + f"— отказ площадки, ждать нечего url={url!r}" + ) html = await _wait_out_pow_challenge(page, provider, url) finally: await page.close() # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server_http_status.py b/tradein-mvp/browser/test_server_http_status.py new file mode 100644 index 00000000..b179a178 --- /dev/null +++ b/tradein-mvp/browser/test_server_http_status.py @@ -0,0 +1,292 @@ +"""test_server_http_status.py — HTTP-статус целевой навигации в /fetch (#3196). + +Проблема, установленная замером на проде 28.08.2026: ДомКлик отдаёт статическую +страницу «403 | Домклик» ровно на 26 624 байта, в которой НЕТ ни startpow, ни +qrator, ни капчи, ни PoW-скрипта. Ни один маркер из _CHALLENGE_MARKERS/_BAN_MARKERS +(оба сняты с авитовских страниц) в неё не попадает, поэтому отказ уезжал наверх как +валидный контент, парсер не находил состояние страницы, и прогон получал блок +неизвестной природы — 14 прогонов domclick_detail_backfill подряд с ban_kind=unknown. + +_fetch_once игнорировал Response, который возвращает page.goto. Теперь статус +целевой навигации сохраняется в _last_response_status[provider] и отдаётся в теле +/fetch рядом с html — АДДИТИВНО, ключ "html" не меняется. + +camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит +test_server_pow_challenge.py). wait_for_timeout на фейковой page — no-op. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_http_status.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path +from typing import Any + +import pytest +from aiohttp.test_utils import make_mocked_request + +# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) +assert _spec is not None and _spec.loader is not None +server = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(server) + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Чистое per-provider состояние на каждый тест (зеркалит соседние тесты).""" + monkeypatch.setattr(server, "_browsers", {}) + monkeypatch.setattr(server, "_browser_cms", {}) + monkeypatch.setattr(server, "_page_counters", {}) + monkeypatch.setattr(server, "_locks", {}) + monkeypatch.setattr(server, "_retry_tasks", {}) + monkeypatch.setattr(server, "_last_goto_at", {}) + monkeypatch.setattr(server, "_last_response_status", {}) + monkeypatch.setattr(server, "_launched_proxy", {}) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False) + + +# Заглушка ДомКлика: 403 и НИ ОДНОГО маркера из _CHALLENGE_MARKERS/_BAN_MARKERS — +# ровно то, на чём ломался старый детектор. +_DOMCLICK_403_HTML = "403 | Домклик" +_REAL_HTML = "listing card content" +# Челлендж Авито: маркер startPow при HTTP 200 — путь, который трогать нельзя. +_CHALLENGE_HTML = "" + + +class _Response: + """Поддельный playwright Response — интересует только .status.""" + + def __init__(self, status: int) -> None: + self.status = status + + +class _Page: + """Поддельная page: goto отдаёт заданный Response, content() — заданный HTML. + + goto_results — по одному элементу на вызов goto (None = «playwright вернул None», + навигационная гонка/редирект). После исчерпания повторяет последний. + """ + + def __init__( + self, + html_sequence: list[str], + goto_results: list[_Response | None], + ) -> None: + self._html_sequence = html_sequence + self._goto_results = goto_results + self._content_calls = 0 + self.goto_urls: list[str] = [] + self.wait_for_timeout_calls: list[int] = [] + self.closed = 0 + + async def route(self, pattern: str, handler: Any) -> None: + return None + + async def goto(self, url: str, **kwargs: Any) -> _Response | None: + idx = min(len(self.goto_urls), len(self._goto_results) - 1) + self.goto_urls.append(url) + return self._goto_results[idx] + + async def wait_for_timeout(self, ms: int) -> None: + self.wait_for_timeout_calls.append(ms) + + async def content(self) -> str: + idx = min(self._content_calls, len(self._html_sequence) - 1) + self._content_calls += 1 + return self._html_sequence[idx] + + async def close(self) -> None: + self.closed += 1 + + +class _Browser: + def __init__(self, page: _Page) -> None: + self._page = page + + async def new_page(self) -> _Page: + return self._page + + +def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None: + server._browsers[provider] = _Browser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) + monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {}) + monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) + + +def _json_body(response: Any) -> dict[str, Any]: + return json.loads(response.body.decode()) + + +async def _coro(value: Any) -> Any: + return value + + +def _make_request(body: dict[str, Any]) -> Any: + request = make_mocked_request("POST", "/fetch") + request.json = lambda: _coro(body) # type: ignore[method-assign] + return request + + +# ── _status_of: чтение статуса ──────────────────────────────────────────────── + + +def test_status_of_reads_response_status() -> None: + assert server._status_of(_Response(403)) == 403 + + +def test_status_of_none_response_is_none() -> None: + """goto вернул None (редирект/навигационная гонка) — не падаем, статуса нет.""" + assert server._status_of(None) is None + + +# ── _fetch_once: статус пишется в per-provider состояние ────────────────────── + + +def test_fetch_once_records_target_status(monkeypatch: pytest.MonkeyPatch) -> None: + page = _Page([_DOMCLICK_403_HTML], [_Response(403)]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://domclick.ru/card/1")) + + assert html == _DOMCLICK_403_HTML # контент отдан как раньше — правка аддитивна + assert server._last_response_status["avito"] == 403 + + +def test_fetch_once_status_none_when_goto_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + page = _Page([_REAL_HTML], [None]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert html == _REAL_HTML + assert server._last_response_status["avito"] is None + + +def test_fetch_once_status_taken_from_target_not_origin(monkeypatch: pytest.MonkeyPatch) -> None: + """Прогрев origin отдал 200, целевая навигация — 403. Наверх идёт 403.""" + page = _Page([_DOMCLICK_403_HTML], [_Response(200), _Response(403)]) + _install(monkeypatch, page) + + asyncio.run( + server._fetch_once("avito", "https://domclick.ru/card/1", origin="https://domclick.ru/") + ) + + assert page.goto_urls == ["https://domclick.ru/", "https://domclick.ru/card/1"] + assert server._last_response_status["avito"] == 403 + + +def test_fetch_once_resets_stale_status_before_navigation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Статус прошлого запроса не должен пережить следующую навигацию.""" + server._last_response_status["avito"] = 403 + page = _Page([_REAL_HTML], [None]) + _install(monkeypatch, page) + + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert server._last_response_status["avito"] is None + + +# ── статус в диагностике: 403/429 не ждут PoW ───────────────────────────────── + + +def test_fetch_once_skips_pow_wait_on_403(monkeypatch: pytest.MonkeyPatch) -> None: + """403 + маркеры челленджа — отказ, а не проверка: бюджет ожидания не тратим. + + Заглушку наверх НЕ отдаём: вернув её как валидный HTML, воскресили бы #3045 — + парсер Авито получил бы страницу челленджа вместо карточки. + """ + page = _Page([_CHALLENGE_HTML], [_Response(403)]) + _install(monkeypatch, page) + monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert page.wait_for_timeout_calls == [] # ни одного тика ожидания челленджа + + +def test_fetch_once_skips_pow_wait_on_429(monkeypatch: pytest.MonkeyPatch) -> None: + page = _Page([_CHALLENGE_HTML], [_Response(429)]) + _install(monkeypatch, page) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert page.wait_for_timeout_calls == [] + + +def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 200 с PoW-заглушкой (путь Авито) не задет: ждём и получаем контент.""" + page = _Page([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML], [_Response(200)]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert html == _REAL_HTML + assert page.wait_for_timeout_calls # ожидание челленджа реально шло + + +def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None: + """Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан.""" + page = _Page(["Доступ ограничен: проблема с IP"], [_Response(200)]) + _install(monkeypatch, page) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + +# ── /fetch: статус доезжает в тело ответа ───────────────────────────────────── + + +def test_fetch_handler_returns_status_next_to_html(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server, "IS_PROD", False) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str: + server._last_response_status[provider] = 403 + return _DOMCLICK_403_HTML + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) + + response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"}))) + + assert response.status == 200 + body = _json_body(response) + assert body["html"] == _DOMCLICK_403_HTML # старый ключ не тронут + assert body["status"] == 403 + + +def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPatch) -> None: + """Статуса нет (goto вернул None) — ответ не ломается, status=null.""" + monkeypatch.setattr(server, "IS_PROD", False) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str: + server._last_response_status[provider] = None + return _REAL_HTML + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch) + + response = asyncio.run(server.fetch_handler(_make_request({"url": "https://www.avito.ru/x"}))) + + body = _json_body(response) + assert body["html"] == _REAL_HTML + assert body["status"] is None diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 3dea2b6e..e1b00841 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -254,6 +254,38 @@ async def probe_proxy_via_browser( return True, None, f"html_len={len(html)}" +# ── классификация отказа по HTTP-статусу (#3196) ───────────────────────────── +# Значения совпадают с CHECK-ограничением scrape_runs.ban_kind: допустимы только +# "platform", "infra", "unknown" либо NULL. Здесь "unknown" НЕ возвращается +# намеренно: статус, который ни о чём не говорит (200/404/None), не повод объявлять +# блок — решение остаётся за вызывающим кодом, который знает остальной контекст. +_BAN_KIND_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 429}) + + +def ban_kind_from_status(status: int | None) -> str | None: + """Раскладывает HTTP-статус ответа площадки в ``scrape_runs.ban_kind``. + + 403/429 → "platform" (площадка отказала осознанно: WAF/rate-limit); + 5xx → "infra" (сбой на стороне площадки/прокси, не адресный бан); + всё остальное и None → None (статус ничего не сообщает о блоке). + + Args: + status: HTTP-код целевой навигации (``BrowserFetcher.last_response_status``) + либо None, если сайдкар статус не отдал (старая версия / goto вернул None). + + Returns: + "platform" | "infra" | None — значение, пригодное для записи в + ``scrape_runs.ban_kind`` как есть. + """ + if status is None: + return None + if status in _BAN_KIND_REFUSAL_STATUSES: + return "platform" + if 500 <= status <= 599: + return "infra" + return None + + class BrowserFetcher: """Async context manager: HTTP-клиент к tradein-browser HTTP-сервису. @@ -332,6 +364,13 @@ class BrowserFetcher: self._environment = environment self._lease: ProxyLease | None = None self._lease_fail_streak: int = 0 + # HTTP-статус ПОСЛЕДНЕЙ целевой навигации сайдкара (#3196). Публичный + # атрибут, а не возврат fetch(): поток управления менять нельзя — fetch() + # по-прежнему отдаёт str и по-прежнему не бросает там, где не бросал. + # Обновляется КАЖДЫМ _post_fetch (в т.ч. на неудаче — сбрасывается в None), + # читается сразу после await fetch(...). None = статуса нет: сайдкар старой + # версии без ключа "status", goto вернул None, либо фетч упал транспортом. + self.last_response_status: int | None = None # ── lifecycle ────────────────────────────────────────────────────────────── @@ -396,6 +435,11 @@ class BrowserFetcher: При HTTPError или ConnectError делает одну повторную попытку после короткой паузы. Остальные исключения всплывают к вызывающему коду. + Побочный эффект (#3196): после возврата ``self.last_response_status`` содержит + HTTP-код целевой навигации (или None, если сайдкар его не отдал). Читать + СРАЗУ после await — следующий fetch() его перезапишет. Классифицировать + помогает ``ban_kind_from_status`` (в этом же модуле). + Returns: Полный HTML-контент страницы. """ @@ -707,6 +751,10 @@ class BrowserFetcher: (#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация при N подряд провалах) — best-effort, саму ошибку не глотает (re-raise). + + Побочный эффект (#3196): выставляет ``self.last_response_status`` — HTTP-код + целевой навигации из тела ответа (``{"html": ..., "status": ...}``). На любой + неудаче сбрасывает в None, чтобы не отдать статус прошлого запроса. """ assert self._client is not None assert self._endpoint is not None @@ -728,11 +776,20 @@ class BrowserFetcher: try: resp = await self._client.post(f"{self._endpoint}/fetch", json=payload) _raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код - data: dict[str, str] = resp.json() + data: dict = resp.json() html = data["html"] except Exception: + self.last_response_status = None self._report_fetch_result(False) raise + # Аддитивно: ключа "status" может не быть (сайдкар старой версии) и он + # может быть null — оба случая дают None, исключений не бросаем. + # bool отсекаем явно: он подтип int, поэтому JSON true прошёл бы проверку + # и уехал бы наверх статусом True, который дальше сравнивается с 403/5xx + # как 1 — молчаливо неверный диагноз вместо честного «статуса нет». + raw_status = data.get("status") + has_status = isinstance(raw_status, int) and not isinstance(raw_status, bool) + self.last_response_status = raw_status if has_status else None self._report_fetch_result(True) logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html)) return html diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/domclick_exceptions.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/domclick_exceptions.py index a98a452c..024d53dd 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/domclick_exceptions.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/domclick_exceptions.py @@ -36,8 +36,19 @@ class DomClickBlockedError(ProxyBanError): curl-слоя (см. avito_exceptions.AvitoBlockedError для того же паттерна). Прямая двойная база `(Exception, ProxyBanError)` даёт MRO-конфликт (ProxyBanError уже сам наследует Exception) — единственная база решает это чище. + + `status` (#3196) — HTTP-код ответа площадки, если он известен + (`BrowserFetcher.last_response_status`). Одно и то же исключение поднимается и + на распознанном маркере, и на сбое браузерного фетча — по счётчикам они были + неразличимы, и прогон получал ban_kind='unknown'. Статус разводит их без + размножения типов: 403/429 — отказ площадки, 5xx — инфраструктура, None — + правда «не знаем» (сайдкар статус не отдал либо фетч не состоялся). """ + def __init__(self, *args: object, status: int | None = None) -> None: + super().__init__(*args) + self.status = status + class DomClickParseError(Exception): """DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 93e38182..ef935261 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -523,7 +523,10 @@ async def fetch_detail( зонды с одного IP жгут его репутацию, свежий/чистый IP проходит с первого раза, инъекция cookies валидной аутентифицированной сессии обходит блок даже на уже подозрительном IP — issue #2000). Оркестратор - считает это блоком и помечает listing failed. + считает это блоком и помечает listing failed. Что именно + произошло, видно по атрибуту `.status` (#3196): 403/429 — отказ + площадки, 5xx — инфраструктура площадки/прокси, None — статус + неизвестен (сайдкар его не отдал либо фетч не состоялся). DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы). """ parsed = urlsplit(card_url) @@ -532,6 +535,9 @@ async def fetch_detail( # Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность # холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04). html = await browser_fetcher.fetch(card_url, origin=origin, cookies=cookies) + # #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет. + # getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать. + status = getattr(browser_fetcher, "last_response_status", None) except DomClickBlockedError: # Defensive passthrough — browser_fetcher.fetch() сегодня НЕ поднимает # DomClickBlockedError сама (она domclick-specific, fetch() про неё не знает), @@ -544,11 +550,16 @@ async def fetch_detail( # обычный сбой транспорта. mark_health(ok=False) её уже учла внутри # browser_fetcher._post_fetch (см. #2600 п.4 — различимость трёх причин). raise DomClickBlockedError( - f"DomClick detail browser fetch failed for {card_url}: {exc}" + f"DomClick detail browser fetch failed for {card_url}: {exc}", + status=getattr(browser_fetcher, "last_response_status", None), ) from exc try: return parse_detail_html(html, card_url) - except DomClickBlockedError: + except DomClickBlockedError as blocked: + # #3196: маркер поймал parse_detail_html, статус ответа знает только этот + # кадр — дописываем его в исключение, иначе оркестратор снова получит блок + # без причины (страница `403 | Домклик` неотличима от QRATOR-челленджа). + blocked.status = status # #2600 п.1: ЗДЕСЬ — настоящий маркер-детект (QRATOR/капча HTML, HTTP 200), # ГЕНУИННЫЙ ban-сигнал в отличие от except-веток выше. browser_fetcher — # ещё живой lease (caller держит `async with BrowserFetcher(...) as bf:`