Merge pull request 'fix(tradein/avito): reap dead 404 listings in detail backfill (don't trip block breaker)' (#2047) from fix/tradein-avito-detail-dead-listing-reaper into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m27s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 48s
This commit is contained in:
commit
d651dac99a
5 changed files with 205 additions and 2 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: специфичный <title>; тело тоже содержит generic-витринный текст,
|
||||
# который сам по себе тригерит soft-block — поэтому not-found проверяется ПЕРВЫМ.
|
||||
_NOT_FOUND_HTML = (
|
||||
'<!DOCTYPE html><html lang="ru"><head>'
|
||||
"<title>Ошибка 404. Страница не найдена</title>"
|
||||
"</head><body><div>Объявления на сайте Авито</div></body></html>"
|
||||
)
|
||||
# Soft-block deflect: generic-витрина БЕЗ 404-текста — это блок, НЕ gone.
|
||||
_SOFT_BLOCK_HTML = (
|
||||
'<!DOCTYPE html><html lang="ru"><head>'
|
||||
"<title>Авито — Объявления на сайте Авито</title>"
|
||||
"</head><body><div>...</div></body></html>"
|
||||
)
|
||||
_ITEM_VIEW_HTML = "<html><body><span data-marker='item-view/item-id'>№ 123</span></body></html>"
|
||||
|
||||
_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()
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue