diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py
index 56d83a16..d6d68bf2 100644
--- a/tradein-mvp/backend/app/services/cian_session.py
+++ b/tradein-mvp/backend/app/services/cian_session.py
@@ -71,6 +71,24 @@ _MFE_AUTH = "header-frontend"
# Callers that need to distinguish ban from valid auth should check state.get("_ban").
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True}
+# audit-scrapers finding 4: verify_session раньше сводило 5xx / сетевой сбой /
+# смену вёрстки к тому же None, что и реальный логаут (401 / isAuthenticated=false) —
+# вызывающие (_cian_pre_claim, admin upload/auto-login) реагировали "куки протухли,
+# перезалей" там, где куки ни при чём (Cian недоступен ИЛИ scraper_kit.cian_state_parser
+# больше не находит header-frontend initialState). Два отдельных сигнала ниже НЕ
+# триггерят "cookies expired" алерт у вызывающих.
+
+# Cian источник недоступен прямо сейчас (5xx-ответ ИЛИ сетевой/транспортный сбой —
+# timeout, DNS, connection reset). Cookies могут быть абсолютно валидны — просто
+# нечем было их проверить. Retry позже, БЕЗ пометки session invalid.
+VERIFY_SOURCE_UNAVAILABLE_SENTINEL: dict[str, Any] = {"_source_unavailable": True}
+
+# HTTP 200 получен, но ожидаемый auth-state (header-frontend/initialState с
+# user.isAuthenticated) не найден/не распарсился — Cian изменил вёрстку/MFE-схему.
+# Это engineering-проблема (extract_state/_MFE_AUTH нужно обновить), НЕ протухшие
+# cookies — переставлять куки здесь бесполезно.
+VERIFY_MARKUP_CHANGED_SENTINEL: dict[str, Any] = {"_markup_changed": True}
+
def _classify_verify_response(
status_code: int,
@@ -79,19 +97,25 @@ def _classify_verify_response(
"""Pure classifier — maps (status_code, html) to verify_session outcome.
Returns:
- VERIFY_BAN_SENTINEL — 403/TLS ban (cookies may be fine, server is blocking)
- None — 401 or isAuthenticated=false (cookies genuinely expired)
- state dict — authenticated successfully
+ VERIFY_BAN_SENTINEL — 403/TLS ban (cookies могут быть в порядке,
+ блокирует сервер)
+ VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/иной non-200 без содержимого —
+ источник недоступен, НЕ cookies
+ VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200, но auth-state не найден/не
+ распарсился — вёрстка/схема изменилась
+ None — 401 ИЛИ isAuthenticated=false — cookies
+ ДЕЙСТВИТЕЛЬНО протухли/разлогинены
+ state dict — authenticated successfully
"""
if status_code == 403:
return VERIFY_BAN_SENTINEL
if status_code == 401:
return None
- if html is None:
- return None
+ if status_code != 200 or html is None:
+ return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
if state is None:
- return None
+ return VERIFY_MARKUP_CHANGED_SENTINEL
user = state.get("user", {}) or {}
if not user.get("isAuthenticated"):
return None
@@ -104,11 +128,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
TLS-fingerprint bans that httpx would trigger.
- Returns:
- state dict — authenticated (contains user.isAuthenticated + userId)
- VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies may still be valid —
- callers should NOT trigger a cookie-refresh alert
- None — HTTP 401 or isAuthenticated=false; cookies expired
+ Returns (проверяй через `is`, НЕ `==` — это sentinel-объекты):
+ state dict — authenticated (user.isAuthenticated + userId)
+ VERIFY_BAN_SENTINEL — HTTP 403 TLS/bot ban; cookies могут быть
+ валидны — НЕ триггерить cookie-refresh alert
+ VERIFY_SOURCE_UNAVAILABLE_SENTINEL — 5xx/network/timeout; источник недоступен,
+ НЕ триггерить cookie-refresh alert, retry позже
+ VERIFY_MARKUP_CHANGED_SENTINEL — HTTP 200 но auth-state не распарсился;
+ Cian изменил вёрстку — НЕ cookie-проблема,
+ нужен engineering-фикс extract_state/_MFE_AUTH
+ None — HTTP 401 или isAuthenticated=false; cookies
+ ДЕЙСТВИТЕЛЬНО протухли — здесь и только здесь
+ имеет смысл просить re-upload
Никогда не логирует сырые значения cookies.
"""
@@ -134,6 +165,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
logger.warning(
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired"
)
+ elif result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL:
+ logger.warning(
+ "Cian cookies verify: source unavailable (status=%d) — "
+ "cookies NOT marked expired, retry later",
+ status,
+ )
+ elif result is VERIFY_MARKUP_CHANGED_SENTINEL:
+ logger.error(
+ "Cian cookies verify: HTTP 200 but auth-state not found/parseable "
+ "(mfe=%s) — markup/schema changed, cookies NOT marked expired",
+ _MFE_AUTH,
+ )
elif result is None:
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
else:
@@ -142,8 +185,11 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
return result
except Exception as exc:
- logger.warning("Cian cookies verify failed: %s", exc)
- return None
+ # Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
+ # недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
+ # возвращался None, конфлируя с реальным логаутом.
+ logger.warning("Cian cookies verify: transport/network error — %s", exc)
+ return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def save_session(
diff --git a/tradein-mvp/backend/tests/test_avito_detail_publish_date_year_rollover.py b/tradein-mvp/backend/tests/test_avito_detail_publish_date_year_rollover.py
new file mode 100644
index 00000000..987cccf0
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_avito_detail_publish_date_year_rollover.py
@@ -0,0 +1,94 @@
+"""Audit-scrapers finding 3: Avito detail publish_date year-boundary rollover.
+
+Avito не показывает год для дат текущего года («20 декабря в 15:30»). Раньше
+`_extract_meta` всегда брал ТЕКУЩИЙ год момента парсинга — объявлению, опубликованному
+в декабре и прочитанному в январе следующего года, ставился год парсинга (будущая
+дата), завышая свежесть лота. Фикс: если получившаяся дата оказалась в будущем
+относительно момента парсинга — откатываем на год назад.
+
+Refs: audit-scrapers 2026-07-26, finding 3 (low).
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import date as real_date
+
+import pytest
+from selectolax.parser import HTMLParser
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.providers.avito import detail as kit_detail
+
+
+def _freeze_today(monkeypatch: pytest.MonkeyPatch, frozen: real_date) -> None:
+ """Подменяет `date` в scraper_kit.providers.avito.detail так, что date.today()
+ детерминированно возвращает `frozen` (date — immutable C-тип, .today нельзя
+ monkeypatch'нуть напрямую — подменяем ссылку на класс в модуле)."""
+
+ class _FrozenDate(real_date):
+ @classmethod
+ def today(cls) -> real_date: # type: ignore[override]
+ return frozen
+
+ monkeypatch.setattr(kit_detail, "date", _FrozenDate)
+
+
+def _tree_with_publish_text(text: str) -> HTMLParser:
+ html = f'
{text}
'
+ return HTMLParser(html)
+
+
+def test_december_publish_date_read_in_january_rolls_back_a_year(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Объявление '20 декабря' парсится 5 января СЛЕДУЮЩЕГО года: без фикса
+ дата была бы 2027-12-20 (в будущем относительно today=2027-01-05) — теперь
+ откатывается на 2026-12-20."""
+ _freeze_today(monkeypatch, real_date(2027, 1, 5))
+ tree = _tree_with_publish_text("№ 4291500000 · 20 декабря в 15:30")
+
+ publish_date, _, _ = kit_detail._extract_meta(tree)
+
+ assert publish_date == real_date(2026, 12, 20)
+
+
+def test_same_year_past_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Control: дата в прошлом (не будущем) в том же году — год НЕ откатывается."""
+ _freeze_today(monkeypatch, real_date(2027, 1, 5))
+ tree = _tree_with_publish_text("№ 4291500001 · 3 января в 09:00")
+
+ publish_date, _, _ = kit_detail._extract_meta(tree)
+
+ assert publish_date == real_date(2027, 1, 3)
+
+
+def test_publish_date_equal_to_today_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Control: дата ровно = today (не строго будущее) — год НЕ откатывается."""
+ _freeze_today(monkeypatch, real_date(2027, 1, 5))
+ tree = _tree_with_publish_text("№ 4291500002 · 5 января в 12:00")
+
+ publish_date, _, _ = kit_detail._extract_meta(tree)
+
+ assert publish_date == real_date(2027, 1, 5)
+
+
+def test_mid_year_publish_date_not_rolled_back(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Обычный случай вдали от границы года — поведение не меняется."""
+ _freeze_today(monkeypatch, real_date(2027, 6, 15))
+ tree = _tree_with_publish_text("№ 4291500003 · 20 марта в 10:00")
+
+ publish_date, _, _ = kit_detail._extract_meta(tree)
+
+ assert publish_date == real_date(2027, 3, 20)
+
+
+def test_no_publish_date_in_text_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Regression guard: отсутствие даты в тексте по-прежнему даёт None (не падает)."""
+ _freeze_today(monkeypatch, real_date(2027, 1, 5))
+ tree = _tree_with_publish_text("№ 4291500004")
+
+ publish_date, _, _ = kit_detail._extract_meta(tree)
+
+ assert publish_date is None
diff --git a/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py b/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
new file mode 100644
index 00000000..f28ed174
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
@@ -0,0 +1,206 @@
+"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.
+
+Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
+бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» —
+неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
+"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
+(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
+сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):
+
+ - page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise.
+ - page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка.
+ - page>1, 0 карточек → всегда graceful end-of-pagination (не regressed).
+ - exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
+ всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки
+ внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).
+
+Refs: audit-scrapers 2026-07-26, finding 1 (medium).
+"""
+
+from __future__ import annotations
+
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.avito_exceptions import AvitoContentBlockedError
+from scraper_kit.base import ScrapedLot
+from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper
+
+from app.services.scraper_adapters import RealScraperConfig
+
+# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
+# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
+_NO_MARKER_HTML = "" + ("x" * 500) + ""
+
+# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
+_NO_RESULTS_HTML = (
+ "По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
+ + ("y" * 200)
+ + ""
+)
+
+# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
+# используется только для проверки, что page>1 остаётся graceful независимо от
+# содержимого (проверка применяется ТОЛЬКО к page==1).
+_BLOCKPAGE_HTML = "Доступ ограничен
"
+
+
+def _make_lot(source_id: str) -> ScrapedLot:
+ return ScrapedLot(
+ source="avito",
+ source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
+ source_id=source_id,
+ price_rub=6_000_000,
+ )
+
+
+# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
+ s = AvitoScraper(RealScraperConfig())
+ with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
+ with pytest.raises(AvitoContentBlockedError):
+ await s.fetch_city_wide(pages=5, delay_override_sec=0)
+
+
+@pytest.mark.asyncio
+async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
+ s = AvitoScraper(RealScraperConfig())
+ with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
+ result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
+ """page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
+ firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
+ s = AvitoScraper(RealScraperConfig())
+ call_n = 0
+
+ async def _fetch(url: str, page: int) -> str:
+ return "page1" if page == 1 else _BLOCKPAGE_HTML
+
+ def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
+ nonlocal call_n
+ call_n += 1
+ return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []
+
+ with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
+ with patch.object(s, "_parse_html", side_effect=_parse):
+ with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
+ result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
+
+ assert len(result) == 2
+ assert call_n == 2 # page1(2 lots) + page2(0 lots) → stop, no raise
+
+
+# ── fetch_by_rooms ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
+ s = AvitoScraper(RealScraperConfig())
+ with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
+ with pytest.raises(AvitoContentBlockedError):
+ await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
+
+
+@pytest.mark.asyncio
+async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
+ s = AvitoScraper(RealScraperConfig())
+ with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
+ result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
+ assert result == []
+
+
+# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────
+
+
+@pytest.mark.asyncio
+async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
+ """Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
+ но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет."""
+ s = AvitoScraper(RealScraperConfig())
+ seen: dict[str, ScrapedLot] = {}
+
+ with patch.object(s, "_parse_html", return_value=[]):
+ with pytest.raises(AvitoContentBlockedError):
+ await s._paginate_leaf_bucket(
+ room_slug="studii-ASgBAgICAUSSA8YQ",
+ room_label="studio",
+ lo=0,
+ hi=3_000_000,
+ html="probe-page-1",
+ max_pages=1,
+ seen=seen,
+ price_cap_per_bucket=1400,
+ max_pages_per_bucket=100,
+ concurrency=5,
+ secondary_only=True,
+ on_bucket=None,
+ skip_buckets=None,
+ expected_total=5,
+ )
+ assert seen == {}
+
+
+@pytest.mark.asyncio
+async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
+ """Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
+ здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
+ s = AvitoScraper(RealScraperConfig())
+ seen: dict[str, ScrapedLot] = {}
+
+ with patch.object(s, "_parse_html", return_value=[]):
+ # Не должно поднимать исключение.
+ await s._paginate_leaf_bucket(
+ room_slug="studii-ASgBAgICAUSSA8YQ",
+ room_label="studio",
+ lo=0,
+ hi=3_000_000,
+ html=None,
+ max_pages=1,
+ seen=seen,
+ price_cap_per_bucket=1400,
+ max_pages_per_bucket=100,
+ concurrency=5,
+ secondary_only=True,
+ on_bucket=None,
+ skip_buckets=None,
+ expected_total=None,
+ )
+ assert seen == {}
+
+
+@pytest.mark.asyncio
+async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
+ """Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
+ s = AvitoScraper(RealScraperConfig())
+ seen: dict[str, ScrapedLot] = {}
+ lots = [_make_lot("L1"), _make_lot("L2")]
+
+ with patch.object(s, "_parse_html", return_value=lots):
+ await s._paginate_leaf_bucket(
+ room_slug="studii-ASgBAgICAUSSA8YQ",
+ room_label="studio",
+ lo=0,
+ hi=3_000_000,
+ html="probe-page-1",
+ max_pages=1,
+ seen=seen,
+ price_cap_per_bucket=1400,
+ max_pages_per_bucket=100,
+ concurrency=5,
+ secondary_only=True,
+ on_bucket=None,
+ skip_buckets=None,
+ expected_total=2,
+ )
+ assert set(seen.keys()) == {"L1", "L2"}
diff --git a/tradein-mvp/backend/tests/test_cian_serp_offers_total_mismatch.py b/tradein-mvp/backend/tests/test_cian_serp_offers_total_mismatch.py
new file mode 100644
index 00000000..5e0fcc04
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_cian_serp_offers_total_mismatch.py
@@ -0,0 +1,106 @@
+"""Audit-scrapers finding 2: Cian totalOffers vs results.offers length mismatch.
+
+`_parse_serp_html` извлекает `totalOffers` и `results.offers` из ОДНОГО Redux
+state-блоба (одна SSR-выдача). Раньше `results.offers` пустой при `totalOffers>0`
+логировался WARNING'ом и тихо возвращался `[]` — не считался schema-regression, не
+попадал в мониторинг (`_report_schema_regression`/Glitchtip).
+
+Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
+проверить без номера страницы внутри `_parse_serp_html` (эта функция не знает,
+какая это страница пагинации — дробный порог типа "< 50% от totalOffers" ложно
+сработал бы на легитимной последней частичной странице exhaustive-пагинации,
+которую эта функция не различает). totalOffers=0 (реально пустой поиск) НЕ
+считается регрессией.
+
+Refs: audit-scrapers 2026-07-26, finding 2 (low).
+"""
+
+from __future__ import annotations
+
+import os
+from unittest.mock import MagicMock, patch
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from scraper_kit.providers.cian.serp import CianScraper
+
+from app.services.scraper_adapters import RealScraperConfig
+
+
+def _scraper() -> CianScraper:
+ return CianScraper(RealScraperConfig())
+
+
+def test_total_offers_positive_but_offers_empty_reports_regression() -> None:
+ """totalOffers=5, results.offers=[] — internal contradiction, must report."""
+ s = _scraper()
+ state = {"results": {"totalOffers": 5, "offers": []}}
+ with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
+ with patch.object(s, "_report_schema_regression") as mock_report:
+ lots = s._parse_serp_html("irrelevant")
+
+ assert lots == []
+ mock_report.assert_called_once()
+ (msg,), _ = mock_report.call_args
+ assert "totalOffers=5" in msg
+
+
+def test_total_offers_zero_and_offers_empty_is_valid_empty_search() -> None:
+ """totalOffers=0, offers=[] — легитимная пустая выборка, НЕ регрессия."""
+ s = _scraper()
+ state = {"results": {"totalOffers": 0, "offers": []}}
+ with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
+ with patch.object(s, "_report_schema_regression") as mock_report:
+ lots = s._parse_serp_html("irrelevant")
+
+ assert lots == []
+ mock_report.assert_not_called()
+
+
+def test_total_offers_none_and_offers_empty_is_not_reported_as_regression() -> None:
+ """totalOffers отсутствует/None в state — недостаточно сигнала для regression-репорта
+ (могла быть частично битая state-структура без явного totalOffers>0 контр-сигнала)."""
+ s = _scraper()
+ state = {"results": {"offers": []}}
+ with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
+ with patch.object(s, "_report_schema_regression") as mock_report:
+ lots = s._parse_serp_html("irrelevant")
+
+ assert lots == []
+ mock_report.assert_not_called()
+
+
+def test_offers_present_normal_path_unaffected() -> None:
+ """totalOffers=1, offers содержит 1 запись без cianId/id — не проходит
+ _offer_to_lot, но это уже существующая (0/N offer-level) охрана, не finding 2."""
+ s = _scraper()
+ state = {"results": {"totalOffers": 1, "offers": [{"noId": True}]}}
+ with patch("scraper_kit.providers.cian.serp.extract_state", return_value=state):
+ with patch.object(s, "_report_schema_regression") as mock_report:
+ lots = s._parse_serp_html("irrelevant")
+
+ # offers_data непустой → finding 2 guard не участвует; существующая offer-level
+ # охрана (raw_count>0 and saved_count==0) должна отработать вместо неё.
+ assert lots == []
+ mock_report.assert_called_once()
+ (msg,), _ = mock_report.call_args
+ assert "_offer_to_lot" in msg
+
+
+def test_state_none_extraction_failed_no_regression_report() -> None:
+ """extract_state вернул None (captcha/структура целиком не найдена) — уже
+ существующая ветка, НЕ должна триггерить finding-2 regression report."""
+ s = _scraper()
+ with patch("scraper_kit.providers.cian.serp.extract_state", return_value=None):
+ with patch.object(s, "_report_schema_regression") as mock_report:
+ lots = s._parse_serp_html("irrelevant")
+
+ assert lots == []
+ mock_report.assert_not_called()
+
+
+def test_report_schema_regression_swallows_missing_glitchtip_dsn() -> None:
+ """_report_schema_regression не должен падать, если glitchtip_dsn не настроен."""
+ s = _scraper()
+ s._config = MagicMock(glitchtip_dsn=None)
+ s._report_schema_regression("test message") # не должно бросить исключение
diff --git a/tradein-mvp/backend/tests/test_cian_session.py b/tradein-mvp/backend/tests/test_cian_session.py
index 2518ea25..ee4e50e6 100644
--- a/tradein-mvp/backend/tests/test_cian_session.py
+++ b/tradein-mvp/backend/tests/test_cian_session.py
@@ -10,6 +10,8 @@ import pytest
from app.services.cian_session import (
CIAN_REQUIRED_COOKIES,
VERIFY_BAN_SENTINEL,
+ VERIFY_MARKUP_CHANGED_SENTINEL,
+ VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
_classify_verify_response,
load_session,
mark_session_invalid,
@@ -211,14 +213,40 @@ def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatc
assert result == expected
-def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
- """200 but extract_state returns None → None."""
+def test_classify_200_state_missing_returns_markup_changed_sentinel(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """audit-scrapers finding 4: HTTP 200 но extract_state не нашёл auth-state
+ (Cian сменил вёрстку/MFE-схему header-frontend) → VERIFY_MARKUP_CHANGED_SENTINEL,
+ НЕ None. Раньше это конфлировалось с "cookies expired" (реальный логаут)."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
)
result = _classify_verify_response(200, "")
- assert result is None
+ assert result is VERIFY_MARKUP_CHANGED_SENTINEL
+ assert result is not None # НЕ должно триггерить cookie-refresh alert
+
+
+def test_classify_5xx_returns_source_unavailable_sentinel() -> None:
+ """audit-scrapers finding 4: HTTP 500 (источник недоступен) →
+ VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None (cookies тут ни при чём)."""
+ result = _classify_verify_response(500, None)
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
+ assert result is not None
+
+
+def test_classify_502_returns_source_unavailable_sentinel() -> None:
+ """Любой non-200/403/401 статус (напр. 502 bad gateway) — источник недоступен."""
+ result = _classify_verify_response(502, None)
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
+
+
+def test_classify_status_200_html_none_returns_source_unavailable_sentinel() -> None:
+ """Defensive: status=200 но html=None (не должно случаться в проде, но
+ classifier не должен молча вернуть None='expired') → source-unavailable."""
+ result = _classify_verify_response(200, None)
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def test_classify_403_is_distinct_from_401() -> None:
@@ -230,6 +258,28 @@ def test_classify_403_is_distinct_from_401() -> None:
assert expired is None
+def test_classify_all_four_outcomes_are_mutually_distinct() -> None:
+ """audit-scrapers finding 4: expired (401) / ban (403) / source-unavailable (5xx)
+ / markup-changed (200+extract_state=None) — четыре РАЗНЫХ сигнала, ни один не
+ коллапсирует в другой. Только expired (None) должен триггерить re-login alert."""
+ expired = _classify_verify_response(401, None)
+ ban = _classify_verify_response(403, None)
+ source_down = _classify_verify_response(500, None)
+
+ with pytest.MonkeyPatch.context() as mp:
+ mp.setattr("app.services.cian_session.extract_state", lambda html, mfe, key: None)
+ markup_changed = _classify_verify_response(200, "")
+
+ outcomes = [expired, ban, source_down, markup_changed]
+ # None встречается ровно один раз (только expired) — остальные три truthy sentinel'а
+ # и все различны между собой (identity, не equality — это разные dict-объекты).
+ assert outcomes.count(None) == 1
+ assert expired is None
+ non_none = [o for o in outcomes if o is not None]
+ assert len(non_none) == 3
+ assert len({id(o) for o in non_none}) == 3
+
+
# ---------------------------------------------------------------------------
# verify_session (async) — integration with curl_cffi mock
# ---------------------------------------------------------------------------
@@ -317,10 +367,12 @@ async def test_verify_session_not_authenticated_returns_none(
@pytest.mark.asyncio
-async def test_verify_session_state_missing_returns_none(
+async def test_verify_session_state_missing_returns_markup_changed_sentinel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """200 + extract_state returns None → None."""
+ """audit-scrapers finding 4: 200 + extract_state returns None (markup changed)
+ → VERIFY_MARKUP_CHANGED_SENTINEL, НЕ None. Раньше ложно триггерило "cookies
+ expired, please re-upload" для реальной причины "Cian сменил вёрстку"."""
monkeypatch.setattr(
"app.services.cian_session.extract_state",
lambda html, mfe, key: None,
@@ -334,7 +386,42 @@ async def test_verify_session_state_missing_returns_none(
with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"})
- assert result is None
+ assert result is VERIFY_MARKUP_CHANGED_SENTINEL
+ assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_verify_session_5xx_returns_source_unavailable_sentinel() -> None:
+ """audit-scrapers finding 4: HTTP 500 → VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
+ НЕ None. Источник временно недоступен — cookies тут ни при чём, вызывающий
+ не должен помечать сессию invalid / просить re-upload."""
+ mock_session = AsyncMock()
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+ mock_session.get = AsyncMock(return_value=_make_cffi_resp(500))
+
+ with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
+ result = await verify_session({"DMIR_AUTH": "x"})
+
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
+ assert result is not None
+
+
+@pytest.mark.asyncio
+async def test_verify_session_network_error_returns_source_unavailable_sentinel() -> None:
+ """audit-scrapers finding 4: сетевой/транспортный сбой (timeout, connection
+ reset и т.п.) → VERIFY_SOURCE_UNAVAILABLE_SENTINEL, НЕ None. Раньше generic
+ except возвращал None — конфлировал сетевой сбой с протухшими cookies."""
+ mock_session = AsyncMock()
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+ mock_session.get = AsyncMock(side_effect=ConnectionError("connection reset by peer"))
+
+ with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
+ result = await verify_session({"DMIR_AUTH": "x"})
+
+ assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL
+ assert result is not None
@pytest.mark.asyncio
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
index 915c5502..0b6c90e7 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
@@ -994,12 +994,18 @@ def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None
day = int(m_date.group(1))
month_word = m_date.group(2).lower()
month = RUS_MONTHS.get(month_word)
- # Год — текущий (Avito не показывает год для свежих объявлений)
- import datetime
-
- current_year = datetime.date.today().year
if month:
- publish_date = date(current_year, month, day)
+ # Avito не показывает год для свежих объявлений — берём текущий.
+ # audit-scrapers finding 3: если объявление опубликовано в декабре,
+ # а страница парсится в январе СЛЕДУЮЩЕГО года, "текущий год" даёт
+ # дату в будущем (завышает свежесть лота). Если получившаяся дата
+ # оказалась в будущем относительно момента парсинга — откатываем
+ # на год назад (это дата из прошлого года).
+ today = date.today()
+ candidate = date(today.year, month, day)
+ if candidate > today:
+ candidate = date(today.year - 1, month, day)
+ publish_date = candidate
except (ValueError, KeyError):
pass
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
index 9b6e7329..510f0119 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py
@@ -953,6 +953,29 @@ class AvitoScraper(BaseScraper):
return 0
return None
+ def _is_unexpected_empty_page(self, html: str) -> bool:
+ """Отличить «в выборке реально 0 объявлений» от DOM-дрейфа/content-block.
+
+ Вызывается ТОЛЬКО когда `_parse_html` уже вернул 0 карточек на page=1
+ (обход всего города/категории/бакета) — само по себе это неотличимо от
+ «объявлений действительно нет» (#audit-scrapers finding 1).
+
+ `_extract_total_count(html)` даёт независимый от DOM-карточек сигнал
+ (счётчик `page-title/count` либо no-results-маркер):
+ - total_hint == 0 → валидный no-results-маркер найден — НЕ аномалия.
+ - total_hint > 0 → счётчик утверждает, что результаты есть, но карточки
+ (`data-marker="item-*"`) не распознаны — DOM-маркер
+ карточки разошёлся со счётчиком (drift).
+ - total_hint is None → ни счётчика, ни no-results-маркера — тоже
+ подозрительно (captcha/firewall без ожидаемой
+ структуры страницы).
+
+ Returns:
+ True — 0 карточек считается аномалией (нужно поднять
+ ``AvitoContentBlockedError``); False — валидная пустая выборка.
+ """
+ return self._extract_total_count(html) != 0
+
async def _fetch_rooms_page_html(
self,
room_slug: str,
@@ -1282,6 +1305,7 @@ class AvitoScraper(BaseScraper):
secondary_only=secondary_only,
on_bucket=on_bucket,
skip_buckets=skip_buckets,
+ expected_total=total,
)
await walk_price_range(
@@ -1309,6 +1333,7 @@ class AvitoScraper(BaseScraper):
secondary_only: bool,
on_bucket: Callable[..., Any] | None,
skip_buckets: set[str] | None,
+ expected_total: int | None = None,
) -> None:
"""Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket.
@@ -1320,6 +1345,14 @@ class AvitoScraper(BaseScraper):
bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый).
skip_buckets: если bucket_key в skip_buckets — пагинация и on_bucket пропускаются.
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх.
+
+ expected_total: total из probe (``_extract_total_count``), известный ДО вызова
+ (см. finding 1 audit-scrapers). Если задан и > 0, а после пагинации всех
+ ``max_pages`` страниц собрано 0 карточек — это противоречие (тот же probe-html
+ независимо утверждал total>0), т.е. DOM-маркер карточки разошёлся со счётчиком
+ (drift), а не легитимно пустой бакет (тот даёт expected_total=0 и сюда даже не
+ доходит — вызывающий _leaf не паджинирует пустые бакеты). None — probe провалился
+ (best-effort пагинация, отсутствие данных ожидаемо, проверка пропускается).
"""
_lo_param = lo if lo > 0 else None
_hi_param = hi # None → _build_rooms_url не ставит pmax
@@ -1378,6 +1411,23 @@ class AvitoScraper(BaseScraper):
collected_this_bucket = len(bucket_lots)
+ # ── Guard: probe утверждал total>0, но парсинг всех страниц дал 0 карточек ──
+ # (finding 1 audit-scrapers). Тот же probe-html независимо подтвердил, что
+ # результаты есть (_extract_total_count) — 0 карточек здесь не может быть
+ # легитимной пустой выдачей, значит DOM-маркер карточки разошёлся со счётчиком.
+ if expected_total is not None and expected_total > 0 and collected_this_bucket == 0:
+ logger.error(
+ "avito: bucket %s probe expected_total=%d but 0 cards parsed across "
+ "%d page(s) — content-block/DOM-drift suspected",
+ bucket_key,
+ expected_total,
+ max_pages,
+ )
+ raise AvitoContentBlockedError(
+ f"Avito bucket {bucket_key}: probe total={expected_total} but 0 cards "
+ "parsed — content-block/DOM-drift suspected"
+ )
+
# ── Фильтр новостроек (secondary_only) ────────────────────────────────
dropped_nb = 0
if secondary_only:
@@ -1596,6 +1646,18 @@ class AvitoScraper(BaseScraper):
lots = self._parse_html(html, source_url_base=url)
if not lots:
+ if page == 1 and self._is_unexpected_empty_page(html):
+ logger.error(
+ "avito %s SERP page=1 returned HTTP 200 but 0 cards "
+ "(no no-results marker) — likely content-block/captcha or "
+ "DOM-marker drift url=%s",
+ label,
+ url,
+ )
+ raise AvitoContentBlockedError(
+ f"Avito {label} sweep: HTTP 200 with 0 cards on page=1 — "
+ "content-block/DOM-drift suspected"
+ )
logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
break
@@ -1706,6 +1768,18 @@ class AvitoScraper(BaseScraper):
lots = self._parse_html(html, source_url_base=url)
if not lots:
+ if page == 1 and self._is_unexpected_empty_page(html):
+ logger.error(
+ "avito byrooms category=%s page=1 returned HTTP 200 but 0 cards "
+ "(no no-results marker) — likely content-block/captcha or "
+ "DOM-marker drift url=%s",
+ name,
+ url,
+ )
+ raise AvitoContentBlockedError(
+ f"Avito byrooms category={name}: HTTP 200 with 0 cards on "
+ "page=1 — content-block/DOM-drift suspected"
+ )
logger.info(
"avito byrooms category=%s page=%d: 0 lots — end of category",
name,
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
index 7b03fca5..d35e46e1 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
@@ -639,6 +639,25 @@ class CianScraper(BaseScraper):
if inspect.isawaitable(res_cb):
await res_cb
+ def _report_schema_regression(self, message: str) -> None:
+ """Отправить сигнал schema-regression в Glitchtip (если настроен).
+
+ Общий механизм для silent-failure guard'ов `_parse_serp_html` (offer-level
+ parse-failure и totalOffers/results.offers mismatch, audit-scrapers finding 2)
+ — переиспользуется, чтобы обе проверки одинаково попадали в мониторинг, а не
+ только в текстовый лог.
+ """
+ try:
+ if self._config.glitchtip_dsn:
+ # Ленивый импорт: sentry_sdk — app-side error-reporting, не dep
+ # scraper_kit. Только когда glitchtip настроен И случилась
+ # schema-regression.
+ import sentry_sdk
+
+ sentry_sdk.capture_message(message, level="error")
+ except Exception:
+ logger.debug("sentry_sdk report failed (not installed/initialised)", exc_info=True)
+
def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
"""Извлечь offers из Cian Redux state.
@@ -655,18 +674,41 @@ class CianScraper(BaseScraper):
)
return []
- offers_data: list[dict[str, Any]] = state.get("results", {}).get("offers", [])
+ results = state.get("results", {})
+ offers_data: list[dict[str, Any]] = results.get("offers", [])
+ total_offers = results.get("totalOffers")
+
if not offers_data:
- logger.warning(
- "cian state found but results.offers пуст (totalOffers=%s)",
- state.get("results", {}).get("totalOffers", "?"),
- )
+ # audit-scrapers finding 2: totalOffers и results.offers приходят из ОДНОГО
+ # state-блоба (одна SSR-выдача) — если totalOffers>0, а offers пуст, это
+ # внутреннее противоречие payload'а, а не легитимная пагинация (probe/leaf
+ # запрашивают только max_pages = ceil(totalOffers/28), посчитанные из ТОГО ЖЕ
+ # totalOffers, так что «сходили за последнюю страницу» здесь не объясняет 0).
+ # Порог: 0 vs >0 — единственный позиционно-независимый сигнал, который можно
+ # проверить без номера страницы; дробный порог (напр. «< 50% от expected»)
+ # ложно сработал бы на легитимной последней частичной странице пагинации,
+ # которую эта функция не различает.
+ if isinstance(total_offers, int) and total_offers > 0:
+ logger.error(
+ "cian SERP: totalOffers=%d но results.offers пуст — schema "
+ "regression suspected (counter/offers mismatch, not empty search)",
+ total_offers,
+ )
+ self._report_schema_regression(
+ f"cian SERP: totalOffers={total_offers} but results.offers is "
+ "empty — possible schema regression (counter/offers mismatch)"
+ )
+ else:
+ logger.warning(
+ "cian state found but results.offers пуст (totalOffers=%s)",
+ total_offers if total_offers is not None else "?",
+ )
return []
logger.info(
"cian SERP state ok: %d offers (totalOffers=%s)",
len(offers_data),
- state.get("results", {}).get("totalOffers", "?"),
+ total_offers if total_offers is not None else "?",
)
lots: list[ScrapedLot] = []
@@ -684,20 +726,10 @@ class CianScraper(BaseScraper):
"cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression",
raw_count,
)
- try:
- if self._config.glitchtip_dsn:
- # Ленивый импорт: sentry_sdk — app-side error-reporting, не dep
- # scraper_kit. Только когда glitchtip настроен И случилась
- # schema-regression. ImportError глотается общим except ниже.
- import sentry_sdk
-
- sentry_sdk.capture_message(
- f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
- " — possible schema regression",
- level="error",
- )
- except Exception:
- pass # sentry_sdk not installed/initialised in dev
+ self._report_schema_regression(
+ f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
+ " — possible schema regression"
+ )
return lots