Merge pull request 'fix(tradein/backfill): save-False у yandex/avito больше не теряет попытку — attempted == сумма исходов у всех трёх бэкфиллов' (#3347) from fix/3338-backfill-save-false into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Deploy Trade-In / deploy-status (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-05 18:14:06 +00:00
commit 65155e5ccd
4 changed files with 345 additions and 0 deletions

View file

@ -684,6 +684,28 @@ async def run_avito_detail_backfill(
)
if save_detail_enrichment(db, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): карточка взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с
# enriched + blocked + gone + failed, и расхождение читается как
# потерянный отказ площадки. Исход failed: непрошедший UPDATE — не
# успех, не блок и не gone (снятие метит is_active=FALSE сам, по 404).
counters.failed += 1
# Печатаем ОБА идентификатора: WHERE в save ключуется по
# source_id из разобранного HTML (item_id), а не по row-id из
# снимка. При редиректе/подмене карточки строка listing_id
# существует и жива — не нашлась строка с source_id=item_id.
logger.warning(
"avito_detail_backfill: run_id=%d listing %s -- карточка "
"разобрана, но UPDATE не нашёл строку: listing_id=%s item_id=%s "
"(WHERE по item_id из HTML)",
run_id,
source_url,
row["id"],
enrichment.item_id,
)
if use_curl:
items_since_warm += 1
breaker.record_success()

View file

@ -458,6 +458,21 @@ async def run_yandex_detail_backfill(
consecutive_blocks = 0
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3338 (та же дыра, что #3332 у domclick): страница взята и
# разобрана, а UPDATE не задел ни одной строки — объявление
# удалено/деактивировано между снимком и записью. Попытка была,
# исхода не было: attempted переставал сходиться с enriched +
# failed, и расхождение читается как потерянный отказ площадки.
# Исход failed: непрошедший UPDATE — не успех и не блок.
counters.failed += 1
logger.warning(
"yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s "
"-- карточка разобрана, но UPDATE не задел ни одной строки",
run_id,
listing_id,
source_url,
)
except Exception as exc:
counters.failed += 1

View file

@ -31,6 +31,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import httpx # noqa: E402
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
@ -92,6 +93,14 @@ def _empty_pool_block() -> DomClickBlockedError:
return blocked
def _transport_block() -> DomClickBlockedError:
"""Сбой НАШЕЙ стороны (таймаут/5xx сайдкара): httpx-ошибка в __cause__ —
ровно то, что читает `_is_transport_failure` (#3283)."""
blocked = DomClickBlockedError("browser fetch failed")
blocked.__cause__ = httpx.ConnectTimeout("sidecar timed out")
return blocked
async def _run(
fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
) -> tuple[DomClickDetailBackfillResult, MagicMock]:
@ -160,6 +169,25 @@ async def test_missing_row_on_save_keeps_identity() -> None:
)
@pytest.mark.asyncio
async def test_transport_failure_then_empty_pool_counts_each_once() -> None:
"""Контроль двойного начисления на самом пути пула (#3338, просьба ревью).
Транспортный сбой уже начисляет failed и идёт `continue`; следующая попытка
упирается в пустой пул и начисляет failed повторно но СВОЙ, за СВОЮ
попытку. attempted=2 и failed=2, а не 3: правка #3332 не должна начислять
исход второй раз за ту же попытку.
"""
fetch = AsyncMock(side_effect=[_transport_block(), _empty_pool_block()])
counters, _ = await _run(fetch, snapshot=5)
_assert_identity(counters, expected_attempted=2)
assert (counters.failed, counters.blocked, counters.enriched) == (2, 0, 0), (
f"failed={counters.failed} blocked={counters.blocked} enriched={counters.enriched}, "
"ожидали 2/0/0 — по одному отказу нашей стороны на каждую из двух попыток"
)
@pytest.mark.asyncio
async def test_blocked_and_enriched_counted_once() -> None:
"""Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""

View file

@ -0,0 +1,280 @@
"""Тождество счётчиков у соседей domclick: yandex/avito detail-backfill (#3338).
Тот же дефект, что #3332 (см. tests/test_3332_domclick_counter_identity.py):
`counters.attempted` инкрементируется ДО попытки, исход дописывается при разборе
результата а ветка `save_detail_enrichment(...) -> False` исхода не дописывала
вовсе (`if save(...): enriched += 1` без else). Страницу взяли, разобрали, а
UPDATE не задел ни одной строки (объявление удалено/деактивировано между
снимком и записью) попытка была, исхода не было. Расхождение
`attempted - сумма исходов` читается как потерянный отказ площадки.
Разбор ВСЕХ точек выхода из цикла попыток показал, что это единственная дыра у
обоих (у domclick второй был обрыв «пул прокси пуст» у соседей такого пути
нет: `resolve_proxy_url` бросает ProxyPoolExhaustedError ДО цикла):
yandex fetch-исключение failed; non-200 blocked+failed; parseNone
failed; saveFalse БЫЛА ДЫРА; общий except failed.
avito saveFalse БЫЛА ДЫРА; AvitoListingGoneError gone;
Blocked/RateLimited blocked; TimeoutError failed;
общий except failed. Обрывы по budget/SIGTERM стоят ДО
`attempted += 1`, они попытку не создают.
Формы тождества у файлов РАЗНЫЕ, и это не описка:
* yandex: `blocked` документирован как ПОДМНОЖЕСТВО `failed` (dataclass,
#3196) — non-200 инкрементирует оба, поэтому сумма исходов = enriched + failed;
* avito: `blocked`/`gone`/`failed` непересекающиеся корзины, сумма исходов =
enriched + blocked + gone + failed.
Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». Вторым
кейсом на каждый файл идёт контроль на противоположную ошибку что правка не
начисляет исход дважды. Харнессы зеркалят tests/test_3196_yandex_ban_kind.py и
tests/test_3283g_rotate_on_platform_ban.py.
"""
from __future__ import annotations
import os
import sys
from types import SimpleNamespace
from typing import Any
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.avito_exceptions import AvitoBlockedError # noqa: E402
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import ( # noqa: E402
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
from app.tasks.yandex_detail_backfill import ( # noqa: E402
YandexDetailBackfillResult,
run_yandex_detail_backfill,
)
# ── yandex ────────────────────────────────────────────────────────────────────
_Y_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession"
_Y_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse"
_Y_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment"
_Y_RUNS = "app.tasks.yandex_detail_backfill.runs_mod"
_Y_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep"
_Y_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url"
# ── avito ─────────────────────────────────────────────────────────────────────
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
@pytest.fixture(autouse=True)
def _reset_shutdown() -> None:
_sd.reset_shutdown()
yield
_sd.reset_shutdown()
def _assert_identity(
attempted: int, outcomes: int, *, expected_attempted: int, detail: str
) -> None:
assert attempted == expected_attempted, (
f"attempted={attempted}, ожидали {expected_attempted} попыток"
)
assert attempted == outcomes, (
f"тождество нарушено: attempted={attempted}, сумма исходов={outcomes} ({detail}), "
f"потеряно {attempted - outcomes} попыток без исхода"
)
def _assert_yandex_identity(c: YandexDetailBackfillResult, *, expected_attempted: int) -> None:
# blocked ⊆ failed (см. докстринг модуля) — в сумму входит только failed.
_assert_identity(
c.attempted,
c.enriched + c.failed,
expected_attempted=expected_attempted,
detail=f"enriched={c.enriched} failed={c.failed} (blocked={c.blocked} ⊆ failed)",
)
def _assert_avito_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
_assert_identity(
c.attempted,
c.enriched + c.blocked + c.gone + c.failed,
expected_attempted=expected_attempted,
detail=(f"enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}"),
)
def _mock_yandex_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 = "<html>ok</html>"
return resp
def _yandex_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 _run_yandex(
responses: list[MagicMock], *, save_ok: bool, parse_result: Any = None
) -> YandexDetailBackfillResult:
count = len(responses)
db = _mock_yandex_db(count)
with (
patch(_Y_ASYNC_SESSION, _yandex_session_cls(responses)),
patch(_Y_PARSE, return_value=parse_result or MagicMock()),
patch(_Y_SAVE, return_value=save_ok),
patch(_Y_RUNS, MagicMock()),
patch(_Y_SLEEP, new_callable=AsyncMock),
patch(_Y_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")),
):
return await run_yandex_detail_backfill(
db,
run_id=3338,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10},
)
@pytest.mark.asyncio
async def test_yandex_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
counters = await _run_yandex([_resp(200), _resp(200)], save_ok=False)
_assert_yandex_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed, counters.blocked) == (0, 2, 0), (
f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}: "
"непрошедший UPDATE — не успех и не блок площадки"
)
@pytest.mark.asyncio
async def test_yandex_outcomes_counted_once() -> None:
"""Контроль на противоположную ошибку: успех и non-200 — по одному разу.
non-200 инкрементирует и blocked, и failed НАРОЧНО (blocked failed, #3196);
правка #3338 не должна добавлять там третий инкремент.
"""
counters = await _run_yandex([_resp(200), _resp(403)], save_ok=True)
_assert_yandex_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed, counters.blocked) == (1, 1, 1), (
f"enriched={counters.enriched} failed={counters.failed} blocked={counters.blocked}, "
"ожидали 1/1/1 — исход начислен дважды"
)
def _fake_avito_settings() -> MagicMock:
return MagicMock(
scraper_fetch_mode="browser",
avito_detail_backfill_use_curl=False,
detail_backfill_block_ratio_window=20,
detail_backfill_block_ratio_threshold=0.7,
browser_http_endpoint="http://browser:9000",
avito_detail_backfill_rotate_after_attempts=15,
avito_detail_backfill_rotate_on_ban_max=0,
avito_detail_backfill_rotate_on_ban_min_gap=10,
)
def _mock_avito_db(n: int) -> MagicMock:
snapshot = [
{
"id": i + 1,
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
}
for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_avito_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.request_context_reset = MagicMock()
instance.lease_id = 42
return MagicMock(return_value=instance)
async def _run_avito(fetch_results: list[Any], *, save_ok: bool) -> AvitoDetailBackfillResult:
count = len(fetch_results)
db = _mock_avito_db(count)
with (
patch(_A_SETTINGS, _fake_avito_settings()),
patch(_A_SESSION),
patch(_A_SCRAPER),
patch(_A_RUNS, MagicMock()),
patch(_A_BROWSER_FETCHER, _mock_avito_browser_fetcher_cls()),
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
patch(_A_SAVE, return_value=save_ok),
patch(_A_ROTATE_PROXY, AsyncMock()),
patch(_A_SLEEP, new_callable=AsyncMock),
):
return await run_avito_detail_backfill(
db,
run_id=3338,
params={"batch_size": count, "budget_sec": 3600, "max_consecutive_blocks": 10},
)
@pytest.mark.asyncio
async def test_avito_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
counters = await _run_avito([MagicMock(), MagicMock()], save_ok=False)
_assert_avito_identity(counters, expected_attempted=2)
assert (counters.enriched, counters.failed) == (0, 2), (
f"enriched={counters.enriched} failed={counters.failed}: непрошедший UPDATE — не успех"
)
assert (counters.blocked, counters.gone) == (0, 0), (
f"blocked={counters.blocked} gone={counters.gone}: площадка ответила и ничего "
"не снимала — исход отказа НАШЕЙ стороны, чужие корзины трогать нельзя"
)
@pytest.mark.asyncio
async def test_avito_outcomes_counted_once() -> None:
"""Контроль на противоположную ошибку: успех/блок/таймаут — по одному разу."""
counters = await _run_avito(
[MagicMock(), AvitoBlockedError("firewall/soft-block"), TimeoutError("fetch stalled")],
save_ok=True,
)
_assert_avito_identity(counters, expected_attempted=3)
assert (counters.enriched, counters.blocked, counters.failed, counters.gone) == (1, 1, 1, 0), (
f"enriched={counters.enriched} blocked={counters.blocked} "
f"failed={counters.failed} gone={counters.gone}, "
"ожидали 1/1/1/0 — правка #3338 не должна начислять исход дважды"
)