From f7d4b5bccf29949d8c254d418993ad1bf2480bd7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 11:59:39 +0000 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=C2=AB=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20=D0=BD=D0=B5=D1=82=20=D0=B2=20=D0=91=D0=94?= =?UTF-8?q?=C2=BB=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=B1=D0=BE=D0=B5=D0=BC=20(#2464?= =?UTF-8?q?)=20(#2974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/scrapers/domrf_catalog_object.py | 20 +++- .../test_2464_catalog_skipped_counter.py | 96 +++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py diff --git a/backend/app/services/scrapers/domrf_catalog_object.py b/backend/app/services/scrapers/domrf_catalog_object.py index c1756736..41021090 100644 --- a/backend/app/services/scrapers/domrf_catalog_object.py +++ b/backend/app/services/scrapers/domrf_catalog_object.py @@ -344,14 +344,24 @@ async def scrape_catalog_object( session: BrowserSession, obj_id: int, snapshot_date: date, -) -> bool: +) -> bool | None: """Scrape одного объекта: fetch HTML → extract __NEXT_DATA__ → parse → UPDATE. Использует SAVEPOINT (begin_nested) для изоляции per-row ошибок. Логирует результат через logger.info. Returns: - True если UPDATE затронул строку, False при ошибке или 0 rows. + True — UPDATE затронул строку; + None — ПРОПУСК: строки (obj_id, snapshot_date) в БД нет. Это не сбой: + obj_id берутся из БД, но снимок мог смениться между выборкой и + UPDATE'ом. Раньше этот случай возвращал False и попадал в + счётчик failed вместе с настоящими ошибками, а объявленный в + контракте счётчик skipped всегда оставался нулём (#2464); + False — сбой: не скачалось, не распарсилось, упал UPDATE. + + Третье состояние сделано через None, а не через новый Literal, намеренно: + прежние True/False сохраняют смысл, поэтому вызывающие и тесты, полагающиеся + на них, не меняются. """ logger.info("catalog_object scrape start obj_id=%d snapshot_date=%s", obj_id, snapshot_date) @@ -401,7 +411,7 @@ async def scrape_catalog_object( obj_id, snapshot_date, ) - return False + return None logger.info( "catalog_object scraped obj_id=%d fields=%d rows_updated=%d", @@ -498,6 +508,10 @@ async def scrape_catalog_objects( break continue consecutive_waf = 0 + if ok is None: + # Строки в БД нет — это пропуск, а не сбой (см. контракт выше). + stats["skipped"] += 1 + continue if ok: stats["succeeded"] += 1 # Фиксируем сразу, а не одним commit'ом в конце (#2464). Раньше весь diff --git a/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py b/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py new file mode 100644 index 00000000..8e204f38 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py @@ -0,0 +1,96 @@ +"""«Объекта нет в БД» считается пропуском, а не сбоем (#2464). + +`stats["skipped"]` был объявлен в контракте, возвращался и печатался в лог — и никогда не +увеличивался. Все исходы сваливались в `failed`: и WAF-блок, и битый разбор, и «UPDATE +затронул 0 строк». По такому счётчику нельзя отличить временную помеху от настоящей +регрессии разбора, а сам он всегда показывал ноль. + +Законный источник пропуска в коде есть: `obj_id` берутся из БД, но снимок мог смениться +между выборкой и UPDATE'ом — тогда строки `(obj_id, snapshot_date)` уже нет. Ветка с этим +логом («not in DB?») существовала и возвращала False. + +Третье состояние сделано через `None`, а не новым `Literal`: прежние True/False сохраняют +смысл, поэтому существующие вызывающие и тесты не переписываются. Это отдельно проверено +контролем ниже. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import date +from typing import Any +from unittest.mock import MagicMock, patch + +_SNAPSHOT = date(2026, 8, 20) + + +class _FakeSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def warm_up(self) -> None: + return None + + +def _run(outcomes: dict[int, Any]) -> dict[str, Any]: + from app.services.scrapers import domrf_catalog_object as mod + + async def _fake(_db: Any, _s: Any, obj_id: int, _d: date) -> Any: + return outcomes[obj_id] + + with ( + patch.object(mod, "BrowserSession", _FakeSession), + patch.object(mod, "scrape_catalog_object", _fake), + ): + return asyncio.run( + mod.scrape_catalog_objects( + db=MagicMock(), + obj_ids=list(outcomes), + snapshot_date=_SNAPSHOT, + region_code=66, + ) + ) + + +def test_missing_row_counts_as_skipped_not_failed() -> None: + """Пропуск обязан попасть в skipped, а не в failed. + + На origin/main этой ветки нет: skipped остаётся нулём, а всё уходит в failed. + """ + stats = _run({1: True, 2: None, 3: False, 4: None}) + + assert stats["skipped"] == 2, f"skipped={stats['skipped']}, ожидалось 2: {stats}" + assert stats["failed"] == 1, f"failed={stats['failed']}, ожидалось 1 (только настоящий сбой)" + assert stats["succeeded"] == 1 + assert stats["processed"] == 4 + + +def test_counters_sum_to_processed() -> None: + """Контроль: сумма трёх счётчиков сходится с processed. + + Ловит «починку», при которой пропуск считался бы дважды или терялся. + """ + stats = _run({1: True, 2: None, 3: False, 4: True, 5: None, 6: False}) + assert stats["succeeded"] + stats["failed"] + stats["skipped"] == stats["processed"] + + +def test_old_bool_contract_still_works() -> None: + """Контроль: True/False сохраняют прежний смысл. + + Это и есть довод в пользу None вместо нового Literal — существующие вызывающие + и тесты (напр. test_domrf_catalog_object_browsersession_throttle) не меняются. + """ + stats = _run({1: True, 2: True, 3: False}) + assert stats["succeeded"] == 2 + assert stats["failed"] == 1 + assert stats["skipped"] == 0