fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты (#2535)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m34s

This commit is contained in:
lekss361 2026-07-26 22:33:54 +00:00
parent dce2cd2040
commit 35f5c3426b
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"). # Callers that need to distinguish ban from valid auth should check state.get("_ban").
VERIFY_BAN_SENTINEL: dict[str, Any] = {"_ban": True} 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( def _classify_verify_response(
status_code: int, status_code: int,
@ -79,19 +97,25 @@ def _classify_verify_response(
"""Pure classifier — maps (status_code, html) to verify_session outcome. """Pure classifier — maps (status_code, html) to verify_session outcome.
Returns: Returns:
VERIFY_BAN_SENTINEL 403/TLS ban (cookies may be fine, server is blocking) VERIFY_BAN_SENTINEL 403/TLS ban (cookies могут быть в порядке,
None 401 or isAuthenticated=false (cookies genuinely expired) блокирует сервер)
state dict authenticated successfully 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: if status_code == 403:
return VERIFY_BAN_SENTINEL return VERIFY_BAN_SENTINEL
if status_code == 401: if status_code == 401:
return None return None
if html is None: if status_code != 200 or html is None:
return None return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
state = extract_state(html, mfe=_MFE_AUTH, key="initialState") state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
if state is None: if state is None:
return None return VERIFY_MARKUP_CHANGED_SENTINEL
user = state.get("user", {}) or {} user = state.get("user", {}) or {}
if not user.get("isAuthenticated"): if not user.get("isAuthenticated"):
return None 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 Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
TLS-fingerprint bans that httpx would trigger. TLS-fingerprint bans that httpx would trigger.
Returns: Returns (проверяй через `is`, НЕ `==` это sentinel-объекты):
state dict authenticated (contains user.isAuthenticated + userId) state dict authenticated (user.isAuthenticated + userId)
VERIFY_BAN_SENTINEL HTTP 403 TLS/bot ban; cookies may still be valid VERIFY_BAN_SENTINEL HTTP 403 TLS/bot ban; cookies могут быть
callers should NOT trigger a cookie-refresh alert валидны НЕ триггерить cookie-refresh alert
None HTTP 401 or isAuthenticated=false; cookies expired 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. Никогда не логирует сырые значения cookies.
""" """
@ -134,6 +165,18 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
logger.warning( logger.warning(
"Cian cookies verify: HTTP 403 TLS/bot ban — cookies NOT marked expired" "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: elif result is None:
logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status) logger.warning("Cian cookies verify: expired/unauthenticated (status=%d)", status)
else: else:
@ -142,8 +185,11 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None:
return result return result
except Exception as exc: except Exception as exc:
logger.warning("Cian cookies verify failed: %s", exc) # Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник
return None # недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде
# возвращался None, конфлируя с реальным логаутом.
logger.warning("Cian cookies verify: transport/network error — %s", exc)
return VERIFY_SOURCE_UNAVAILABLE_SENTINEL
def save_session( 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 ( from app.services.cian_session import (
CIAN_REQUIRED_COOKIES, CIAN_REQUIRED_COOKIES,
VERIFY_BAN_SENTINEL, VERIFY_BAN_SENTINEL,
VERIFY_MARKUP_CHANGED_SENTINEL,
VERIFY_SOURCE_UNAVAILABLE_SENTINEL,
_classify_verify_response, _classify_verify_response,
load_session, load_session,
mark_session_invalid, mark_session_invalid,
@ -211,14 +213,40 @@ def test_classify_200_authenticated_returns_state(monkeypatch: pytest.MonkeyPatc
assert result == expected assert result == expected
def test_classify_200_state_missing_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: def test_classify_200_state_missing_returns_markup_changed_sentinel(
"""200 but extract_state returns None → None.""" 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( monkeypatch.setattr(
"app.services.cian_session.extract_state", "app.services.cian_session.extract_state",
lambda html, mfe, key: None, lambda html, mfe, key: None,
) )
result = _classify_verify_response(200, "<html></html>") 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: 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 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 # verify_session (async) — integration with curl_cffi mock
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -317,10 +367,12 @@ async def test_verify_session_not_authenticated_returns_none(
@pytest.mark.asyncio @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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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( monkeypatch.setattr(
"app.services.cian_session.extract_state", "app.services.cian_session.extract_state",
lambda html, mfe, key: None, 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): with patch("app.services.cian_session.AsyncSession", return_value=mock_session):
result = await verify_session({"DMIR_AUTH": "x"}) 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 @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)) day = int(m_date.group(1))
month_word = m_date.group(2).lower() month_word = m_date.group(2).lower()
month = RUS_MONTHS.get(month_word) month = RUS_MONTHS.get(month_word)
# Год — текущий (Avito не показывает год для свежих объявлений)
import datetime
current_year = datetime.date.today().year
if month: 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): except (ValueError, KeyError):
pass pass

View file

@ -953,6 +953,29 @@ class AvitoScraper(BaseScraper):
return 0 return 0
return None 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( async def _fetch_rooms_page_html(
self, self,
room_slug: str, room_slug: str,
@ -1282,6 +1305,7 @@ class AvitoScraper(BaseScraper):
secondary_only=secondary_only, secondary_only=secondary_only,
on_bucket=on_bucket, on_bucket=on_bucket,
skip_buckets=skip_buckets, skip_buckets=skip_buckets,
expected_total=total,
) )
await walk_price_range( await walk_price_range(
@ -1309,6 +1333,7 @@ class AvitoScraper(BaseScraper):
secondary_only: bool, secondary_only: bool,
on_bucket: Callable[..., Any] | None, on_bucket: Callable[..., Any] | None,
skip_buckets: set[str] | None, skip_buckets: set[str] | None,
expected_total: int | None = None,
) -> None: ) -> None:
"""Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket. """Параллельная пагинация одного leaf-бакета + фильтр + дедуп + on_bucket.
@ -1320,6 +1345,14 @@ class AvitoScraper(BaseScraper):
bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый). bucket_key = "room_label:lo:hi" (закрытый) либо "room_label:lo:open" (открытый).
skip_buckets: если bucket_key в skip_buckets пагинация и on_bucket пропускаются. skip_buckets: если bucket_key в skip_buckets пагинация и on_bucket пропускаются.
AvitoBlockedError/AvitoRateLimitedError из page-фетчей пробрасываются наверх. 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 _lo_param = lo if lo > 0 else None
_hi_param = hi # None → _build_rooms_url не ставит pmax _hi_param = hi # None → _build_rooms_url не ставит pmax
@ -1378,6 +1411,23 @@ class AvitoScraper(BaseScraper):
collected_this_bucket = len(bucket_lots) 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) ──────────────────────────────── # ── Фильтр новостроек (secondary_only) ────────────────────────────────
dropped_nb = 0 dropped_nb = 0
if secondary_only: if secondary_only:
@ -1596,6 +1646,18 @@ class AvitoScraper(BaseScraper):
lots = self._parse_html(html, source_url_base=url) lots = self._parse_html(html, source_url_base=url)
if not lots: 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) logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
break break
@ -1706,6 +1768,18 @@ class AvitoScraper(BaseScraper):
lots = self._parse_html(html, source_url_base=url) lots = self._parse_html(html, source_url_base=url)
if not lots: 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( logger.info(
"avito byrooms category=%s page=%d: 0 lots — end of category", "avito byrooms category=%s page=%d: 0 lots — end of category",
name, name,

View file

@ -639,6 +639,25 @@ class CianScraper(BaseScraper):
if inspect.isawaitable(res_cb): if inspect.isawaitable(res_cb):
await 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]: def _parse_serp_html(self, html: str) -> list[ScrapedLot]:
"""Извлечь offers из Cian Redux state. """Извлечь offers из Cian Redux state.
@ -655,18 +674,41 @@ class CianScraper(BaseScraper):
) )
return [] 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: if not offers_data:
logger.warning( # audit-scrapers finding 2: totalOffers и results.offers приходят из ОДНОГО
"cian state found but results.offers пуст (totalOffers=%s)", # state-блоба (одна SSR-выдача) — если totalOffers>0, а offers пуст, это
state.get("results", {}).get("totalOffers", "?"), # внутреннее противоречие 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 [] return []
logger.info( logger.info(
"cian SERP state ok: %d offers (totalOffers=%s)", "cian SERP state ok: %d offers (totalOffers=%s)",
len(offers_data), len(offers_data),
state.get("results", {}).get("totalOffers", "?"), total_offers if total_offers is not None else "?",
) )
lots: list[ScrapedLot] = [] lots: list[ScrapedLot] = []
@ -684,20 +726,10 @@ class CianScraper(BaseScraper):
"cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression", "cian SERP: 0/%d offers прошли _offer_to_lot — возможна schema regression",
raw_count, raw_count,
) )
try: self._report_schema_regression(
if self._config.glitchtip_dsn: f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
# Ленивый импорт: sentry_sdk — app-side error-reporting, не dep " — possible schema regression"
# 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
return lots return lots