diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index 239ed334..61800152 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -35,7 +35,11 @@ from sqlalchemy.orm import Session from app.services.geocoder import is_within_ekb_bbox_wide from app.services.scrapers.avito import _clean_address, _is_firewall_page -from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError +from app.services.scrapers.avito_exceptions import ( + AvitoBlockedError, + AvitoListingGoneError, + AvitoRateLimitedError, +) from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text if TYPE_CHECKING: @@ -307,6 +311,16 @@ def _is_detail_soft_block(html: str) -> bool: return any(m in head for m in _DETAIL_SOFT_BLOCK_TITLE_MARKERS) +def _is_detail_not_found(html: str) -> bool: + """True if the page is Avito's 404 / removed-listing page (not an anti-bot deflect). + Guard: a real listing has item-view → False. Keyed on the specific 404 title so it + does NOT catch the soft-block deflect ("объявления на сайте авито" without 404 text).""" + if "item-view/item-id" in html or "item-view/title-info" in html: + return False + head = html[:16384].lower() + return "страница не найдена" in head or "ошибка 404" in head + + # ── fetch_detail ────────────────────────────────────────────────────────────── async def fetch_detail( item_url: str, @@ -341,6 +355,15 @@ async def fetch_detail( raise AvitoBlockedError( f"Avito detail browser fetch failed for {full_url}: {exc}" ) from exc + # 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без + # item-view. Это НЕ анти-бот deflect — отдельный сигнал, чтобы backfill + # пометил листинг is_active=FALSE и НЕ крутил consecutive-block breaker + # (run 458: 5 dead подряд → abort до live-листингов). Проверяем ДО + # firewall/soft-block (иначе 404-страница ловится как soft-block). + if _is_detail_not_found(html): + raise AvitoListingGoneError( + f"Avito detail 404/listing-gone (browser-mode) for {full_url}" + ) if _is_firewall_page(html) or _is_detail_soft_block(html): raise AvitoBlockedError( f"Avito detail firewall/soft-block (browser-mode) for {full_url}" diff --git a/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py b/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py index b2ea0103..3895df76 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_exceptions.py @@ -13,6 +13,17 @@ class AvitoRateLimitedError(AvitoError): """HTTP 429 от Avito — rate limit triggered.""" +class AvitoListingGoneError(AvitoError): + """Объявление удалено / вернуло 404 (страница «Ошибка 404» вместо item-view). + + Отдельный сигнал от AvitoBlockedError/AvitoRateLimitedError: 404 — НЕ анти-бот + deflect, а мёртвый листинг (его незачем ротировать/ретраить). Наследует общий + AvitoError, но НЕ AvitoBlockedError — чтобы backfill-ловушка + `except (AvitoBlockedError, AvitoRateLimitedError)` его НЕ перехватывала и не крутила + consecutive-block breaker на мёртвых координатных дырах (run 458: 5 dead → abort). + """ + + class AvitoContentBlockedError(AvitoBlockedError): """HTTP 200 от Avito, но 0 карточек на первой странице — content-block / captcha. diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index ee978633..15e79b6e 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -32,6 +32,7 @@ from app.services.scrapers.avito import AvitoScraper from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment from app.services.scrapers.avito_exceptions import ( AvitoBlockedError, + AvitoListingGoneError, AvitoRateLimitedError, ) from app.services.scrapers.browser_fetcher import BrowserFetcher @@ -51,6 +52,7 @@ class AvitoDetailBackfillResult: attempted: int = 0 enriched: int = 0 blocked: int = 0 + gone: int = 0 failed: int = 0 duration_sec: float = field(default=0.0) @@ -59,6 +61,7 @@ class AvitoDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "blocked": self.blocked, + "gone": self.gone, "failed": self.failed, "duration_sec": int(self.duration_sec), } @@ -236,6 +239,39 @@ async def run_avito_detail_backfill( counters.enriched += 1 consecutive_blocks = 0 + except AvitoListingGoneError: + # #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed. + # Координатные дыры в lat-null очереди в основном dead-листинги; + # browser-mode рендерит их «Ошибка 404» без item-view → раньше это + # ловилось как soft-block → consecutive-block breaker абортил run до + # live-листингов (run 458: attempted=5 enriched=0 blocked=5 → abort). + # 404 нейтрален к breaker'у: consecutive_blocks НЕ трогаем (не растим + # и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope + # (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь. + counters.gone += 1 + try: + with db.begin_nested(): + db.execute( + text("UPDATE listings SET is_active = FALSE WHERE id = :id"), + {"id": row["id"]}, + ) + except Exception: + logger.warning( + "avito_detail_backfill: run_id=%d failed to mark listing %s " + "is_active=FALSE", + run_id, + row["id"], + exc_info=True, + ) + logger.info( + "avito_detail_backfill: run_id=%d GONE #%d/%d listing %s -> " + "marked is_active=FALSE", + run_id, + counters.gone, + len(snapshot), + source_url, + ) + except (AvitoBlockedError, AvitoRateLimitedError) as e: consecutive_blocks += 1 counters.blocked += 1 @@ -310,11 +346,12 @@ async def run_avito_detail_backfill( runs_mod.mark_done(db, run_id, current_counters) logger.info( "avito_detail_backfill: run_id=%d DONE -- attempted=%d enriched=%d " - "blocked=%d failed=%d duration=%.1fs", + "blocked=%d gone=%d failed=%d duration=%.1fs", run_id, counters.attempted, counters.enriched, counters.blocked, + counters.gone, counters.failed, counters.duration_sec, ) diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_not_found.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_not_found.py new file mode 100644 index 00000000..912b7885 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_not_found.py @@ -0,0 +1,82 @@ +"""404 / removed-listing detection на detail-странице (#2034). + +Lat-null pending-очередь backfill в основном содержит МЁРТВЫЕ листинги (404 — потому +они и стали координатными дырами). В browser-mode 404-страница Avito («Ошибка 404. +Страница не найдена», тело тоже содержит generic «Объявления на сайте Авито») не имеет +item-view → раньше ловилась как soft-block → AvitoBlockedError → backfill крутил +consecutive-block breaker и абортил run ДО live-листингов (run 458: attempted=5 +enriched=0 blocked=5 → abort). + +_is_detail_not_found детектит её ОТДЕЛЬНО → fetch_detail кидает AvitoListingGoneError +(НЕ AvitoBlockedError), backfill метит листинг is_active=FALSE и продолжает без +ротации/ретрая. + +Зеркало fixture-стиля test_avito_detail_soft_block.py / test_avito_detail_browser_only.py. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.scrapers import avito_detail as detail_mod +from app.services.scrapers.avito_detail import fetch_detail +from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoListingGoneError + +_SENTINEL = object() + +# 404-страница Avito: специфичный ; тело тоже содержит generic-витринный текст, +# который сам по себе тригерит soft-block — поэтому not-found проверяется ПЕРВЫМ. +_NOT_FOUND_HTML = ( + '<!DOCTYPE html><html lang="ru"><head>' + "<title>Ошибка 404. Страница не найдена" + "
Объявления на сайте Авито
" +) +# Soft-block deflect: generic-витрина БЕЗ 404-текста — это блок, НЕ gone. +_SOFT_BLOCK_HTML = ( + '' + "Авито — Объявления на сайте Авито" + "
...
" +) +_ITEM_VIEW_HTML = "№ 123" + +_ASYNC_SESSION = "app.services.scrapers.avito_detail.AsyncSession" +_BUILD_SESSION = "app.services.scrapers.avito_detail._build_detail_session" +_PARSE = "app.services.scrapers.avito_detail.parse_detail_html" + + +def test_is_detail_not_found_unit() -> None: + """Юнит _is_detail_not_found: 404 → True; soft-block deflect / item-view / пусто → False.""" + assert detail_mod._is_detail_not_found(_NOT_FOUND_HTML) is True + # soft-block deflect (generic-витрина без 404-текста) НЕ должен ловиться как gone + assert detail_mod._is_detail_not_found(_SOFT_BLOCK_HTML) is False + # реальное объявление с item-view → False + assert detail_mod._is_detail_not_found(_ITEM_VIEW_HTML) is False + assert detail_mod._is_detail_not_found("") is False + + +def _browser_returning(html: str) -> MagicMock: + bf = MagicMock() + bf.fetch = AsyncMock(return_value=html) + return bf + + +@pytest.mark.asyncio +async def test_browser_mode_404_raises_listing_gone_not_blocked() -> None: + """browser_fetcher вернул 404-HTML → AvitoListingGoneError (НЕ AvitoBlockedError), + без curl-сессии.""" + bf = _browser_returning(_NOT_FOUND_HTML) + with ( + patch(_ASYNC_SESSION) as async_session, + patch(_BUILD_SESSION) as build_session, + patch(_PARSE, return_value=_SENTINEL) as parse, + ): + with pytest.raises(AvitoListingGoneError) as exc_info: + await fetch_detail("/items/123", browser_fetcher=bf) + + # Тип строго AvitoListingGoneError и НЕ подкласс AvitoBlockedError — + # иначе backfill-ловушка `except (AvitoBlockedError, ...)` перехватила бы его. + assert not isinstance(exc_info.value, AvitoBlockedError) + bf.fetch.assert_awaited_once() + parse.assert_not_called() + async_session.assert_not_called() + build_session.assert_not_called() diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index c285f1ba..5593c6cd 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -324,6 +324,56 @@ async def test_backfill_fetch_timeout_skips_and_continues() -> None: runs.mark_failed.assert_not_called() +@pytest.mark.asyncio +async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None: + """#2034: AvitoListingGoneError (мёртвый 404-листинг) → counters.gone++, blocked + НЕ растёт, consecutive-block breaker НЕ абортит, листинг помечается is_active=FALSE. + + Регрессия run 458 (attempted=5 enriched=0 blocked=5 → abort): lat-null очередь + состоит из dead-листингов; раньше 404 ловился как soft-block → breaker абортил run + до live-листингов. Теперь 404 нейтрален к breaker'у и метит листинг inactive. + """ + from app.services.scrapers.avito_exceptions import AvitoListingGoneError + + snapshot = _make_snapshot(1) + db = _mock_db(snapshot) + runs = MagicMock() + mock_fetch = AsyncMock(side_effect=AvitoListingGoneError("404 gone")) + mock_scraper = MagicMock() + mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True) + fake_settings = MagicMock(scraper_fetch_mode="cffi") + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION, return_value=AsyncMock()), + patch(_SCRAPER, mock_scraper), + patch(_RUNS, runs), + patch(_FETCH, mock_fetch), + patch(_SLEEP, new_callable=AsyncMock), + ): + result = await run_avito_detail_backfill( + db, + run_id=12, + params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}, + ) + + assert result.gone == 1 + assert result.blocked == 0 + assert result.attempted == 1 + assert result.enriched == 0 + # breaker НЕ абортил: rotate_ip НЕ дёргался (gone ≠ block), run завершён mark_done. + mock_scraper.return_value._rotate_ip.assert_not_called() + runs.mark_done.assert_called_once() + runs.mark_failed.assert_not_called() + # UPDATE listings SET is_active = FALSE по row id=1 выполнен (мок db.execute). + update_calls = [ + c + for c in db.execute.call_args_list + if "UPDATE listings SET is_active = FALSE" in str(c.args[0]) + ] + assert len(update_calls) == 1 + assert update_calls[0].args[1] == {"id": 1} + + _BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"