fix(tradein/scrapers): детекция дрейфа разметки вместо тихой пустоты #2535
8 changed files with 695 additions and 44 deletions
|
|
@ -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)
|
блокирует сервер)
|
||||||
|
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
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal file
206
tradein-mvp/backend/tests/test_avito_sweep_dom_drift.py
Normal 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"}
|
||||||
|
|
@ -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") # не должно бросить исключение
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
# 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(
|
logger.warning(
|
||||||
"cian state found but results.offers пуст (totalOffers=%s)",
|
"cian state found but results.offers пуст (totalOffers=%s)",
|
||||||
state.get("results", {}).get("totalOffers", "?"),
|
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:
|
|
||||||
# Ленивый импорт: 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"
|
f"cian SERP: {raw_count}/{raw_count} offers failed _offer_to_lot"
|
||||||
" — possible schema regression",
|
" — possible schema regression"
|
||||||
level="error",
|
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass # sentry_sdk not installed/initialised in dev
|
|
||||||
|
|
||||||
return lots
|
return lots
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue