fix(tradein/scrapers): silent-failure guards for MERA scraper audit findings

Общая тема: скрапер тихо возвращал пустоту вместо сигнала об ошибке, продукт
считал оценку по остаткам данных, не зная что источник отвалился.

1. avito serp.py (medium): citywide/byrooms/exhaustive sweep больше не путает
   DOM-drift с "объявлений нет". _is_unexpected_empty_page() перепроверяет
   независимый сигнал (_extract_total_count — счётчик page-title/count либо
   no-results маркер): page=1 c 0 карточками и БЕЗ no-results маркера →
   AvitoContentBlockedError (переиспользует существующий механизм #754/#779).
   Exhaustive-бакеты (_paginate_leaf_bucket) получают expected_total из probe —
   total>0 но 0 карточек после пагинации всех страниц тоже поднимает ошибку.
   page>1 c 0 карточками остаётся graceful end-of-pagination (не regressed).

2. cian serp.py (low): _parse_serp_html сравнивает totalOffers vs
   results.offers из ОДНОГО state-блоба. totalOffers>0 при пустом offers —
   report как schema regression (elevated до ERROR + Glitchtip через новый
   _report_schema_regression(), общий с существующей offer-level охраной).
   Порог 0-vs->0 выбран т.к. это единственный позиционно-независимый сигнал:
   функция не знает номер страницы, дробный порог ложно сработал бы на
   легитимной последней частичной странице exhaustive-пагинации.

3. avito detail.py (low): publish_date больше не завышает свежесть лота на
   границе года. Если "текущий год + месяц/день" даёт дату в будущем
   относительно момента парсинга (декабрьское объявление читается в январе) —
   откатываем на год назад.

4. cian_session.py (low): verify_session больше не конфлирует expired/
   source-down/markup-changed. Новые VERIFY_SOURCE_UNAVAILABLE_SENTINEL (5xx,
   network/timeout exception) и VERIFY_MARKUP_CHANGED_SENTINEL (HTTP 200 но
   header-frontend auth-state не распарсился) отделены от None (единственный
   сигнал реального логаута: 401 или isAuthenticated=false). Callers
   (product_handlers.py::_cian_pre_claim, admin.py) НЕ обновлены — вне
   разрешённого scope этого PR (только cian_session.py + scraper-kit),
   отдельный follow-up.

Тесты: 19 новых (8 avito sweep drift, 6 cian totalOffers mismatch, 5 date
rollover) + обновлены 2 существующих (cian_session markup-changed вместо
None) под новый, более точный контракт. Полный backend suite:
2649 passed, 8 skipped, 1 deselected (без регрессий).
This commit is contained in:
bot-backend 2026-07-26 23:52:39 +03:00
parent c9ba1b15ba
commit 0db7a026bd
8 changed files with 695 additions and 44 deletions

View file

@ -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(

View file

@ -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'<html><body><div data-marker="item-view/item-id">{text}</div></body></html>'
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

View file

@ -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 = "<html><body>" + ("x" * 500) + "</body></html>"
# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
_NO_RESULTS_HTML = (
"<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
+ ("y" * 200)
+ "</body></html>"
)
# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
# используется только для проверки, что page>1 остаётся graceful независимо от
# содержимого (проверка применяется ТОЛЬКО к page==1).
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></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 "<html>page1</html>" 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="<html>probe-page-1</html>",
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="<html>probe-page-1</html>",
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"}

View file

@ -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("<html>irrelevant</html>")
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("<html>irrelevant</html>")
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("<html>irrelevant</html>")
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("<html>irrelevant</html>")
# 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("<html>irrelevant</html>")
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") # не должно бросить исключение

View file

@ -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, "<html></html>")
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, "<html></html>")
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

View file

@ -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

View file

@ -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,

View file

@ -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