From 35f5c3426b9a498dcaf12c2c6e821ca055b425aa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 26 Jul 2026 22:33:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/scrapers):=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B4=D1=80=D0=B5=D0=B9=D1=84?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=82=D0=B8=D1=85=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D1=82=D1=8B=20(#2535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/cian_session.py | 72 ++++-- ...avito_detail_publish_date_year_rollover.py | 94 ++++++++ .../tests/test_avito_sweep_dom_drift.py | 206 ++++++++++++++++++ .../test_cian_serp_offers_total_mismatch.py | 106 +++++++++ .../backend/tests/test_cian_session.py | 99 ++++++++- .../src/scraper_kit/providers/avito/detail.py | 16 +- .../src/scraper_kit/providers/avito/serp.py | 74 +++++++ .../src/scraper_kit/providers/cian/serp.py | 72 ++++-- 8 files changed, 695 insertions(+), 44 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_avito_detail_publish_date_year_rollover.py create mode 100644 tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py create mode 100644 tradein-mvp/backend/tests/test_cian_serp_offers_total_mismatch.py 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