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: специфичный