diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 78e20839..44109609 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1007,6 +1007,14 @@ class Settings(BaseSettings): # ENV: YANDEX_PROXY_MAX_ROTATIONS. yandex_proxy_max_rotations: int = 4 + # ── Порог полноты detail-страницы Яндекса (#3191) ──────────────────────── + # Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным + # HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к + # структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух + # даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется). + # ENV: YANDEX_DETAIL_MIN_HTML_BYTES. + yandex_detail_min_html_bytes: int = 1_000_000 + @property def yandex_proxy_url(self) -> str | None: """Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url).""" diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 59d6fe99..31e7cad7 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -44,6 +44,14 @@ Why curl_cffi and not YandexDetailScraper.fetch_detail: non-200 ответ считается блоком, а его диагноз берётся из HTTP-статуса (ban_kind_from_status: 403/429 → platform, 5xx → infra, прочее → unknown), а не из текстовых маркеров страницы, снятых с чужой площадки. + +Полнота страницы (#3191): HTTP 200 + разобравшийся parse ещё не означают карточку. +Недорендеренная страница (1,8 МБ вместо 3,9, без блока контактов) парсится молча и +раньше уезжала в БД с detail_enriched_at, выбывая из очереди навсегда. Теперь она +отсеивается ДО parse (detail_incomplete_reason: структурный маркер контактов + +размерный порог settings.yandex_detail_min_html_bytes) и считается исходом +incomplete ⊆ failed — обогащения нет, значит следующий снапшот +(detail_enriched_at IS NULL) возьмёт её снова. """ from __future__ import annotations @@ -57,10 +65,15 @@ from dataclasses import dataclass, field from curl_cffi.requests import AsyncSession from scraper_kit.browser_fetcher import ban_kind_from_status from scraper_kit.providers._base import DEFAULT_IMPERSONATE -from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail_enrichment +from scraper_kit.providers.yandex.detail import ( + YandexDetailScraper, + detail_incomplete_reason, + save_detail_enrichment, +) from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.services import scrape_runs as runs_mod from app.services.proxy_egress import resolve_proxy_url from app.services.scrape_runs import BAN_KIND_UNKNOWN @@ -136,6 +149,11 @@ class YandexDetailBackfillResult: # обогащения») не переписываем. Без этого счётчика ветка перевода прогона в # 'banned' у Яндекса недостижима по построению (#3196). blocked: int = 0 + # Недогруженные страницы среди попыток (#3191): HTTP 200, валидный HTML, но без + # блока контактов / заметно меньше нормы. Тоже подмножество failed — попытка была, + # обогащения не случилось; detail_enriched_at не проставляется, объявление остаётся + # в очереди (снапшот берётся по detail_enriched_at IS NULL). + incomplete: int = 0 failed: int = 0 # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к @@ -150,6 +168,7 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "blocked": self.blocked, + "incomplete": self.incomplete, "failed": self.failed, "url_from_offer_id": self.url_from_offer_id, "unenrichable_pending": self.unenrichable_pending, @@ -419,6 +438,47 @@ async def run_yandex_detail_backfill( break continue + # Недогруз — отказ, а не успех (#3191). Проверка ДО parse: parse + # частичную страницу разберёт молча (JSON-состояние на месте), и + # объявление уедет в БД с detail_enriched_at, выбыв из очереди + # навсегда. Здесь оно исхода 'enriched' не получает, значит в + # следующем прогоне снова попадёт в снапшот (detail_enriched_at + # IS NULL). Серия таких страниц двигает consecutive_none — тот же + # брейкер, что у parse→None: вечно недогружаемая карточка упрётся + # в max_consecutive_blocks и оборвёт прогон, а не будет молотиться + # (per-listing счётчика попыток в схеме нет, см. отчёт #3191). + incomplete_reason = detail_incomplete_reason( + resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes + ) + if incomplete_reason is not None: + counters.incomplete += 1 + counters.failed += 1 + consecutive_none += 1 + # Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196). + consecutive_blocks = 0 + logger.warning( + "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " + "-> недогруженная карточка, отказ: %s (consecutive=%d)", + run_id, + listing_id, + source_url, + incomplete_reason, + consecutive_none, + ) + if consecutive_none >= max_consecutive_blocks: + logger.error( + # consecutive_none смешанный (фетч-ошибка + parse-None + + # недогруз) — «недогруженных» назвало бы только один вклад. + "yandex_detail_backfill: run_id=%d ABORT -- %d подряд " + "без обогащения. enriched=%d attempted=%d", + run_id, + consecutive_none, + counters.enriched, + counters.attempted, + ) + break + continue + enrichment = scraper.parse(resp.text, offer_url=source_url) if enrichment is None: @@ -502,11 +562,12 @@ async def run_yandex_detail_backfill( ) logger.info( "yandex_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d " - "blocked=%d failed=%d duration=%.1fs", + "blocked=%d incomplete=%d failed=%d duration=%.1fs", run_id, counters.attempted, counters.enriched, counters.blocked, + counters.incomplete, counters.failed, counters.duration_sec, ) diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py index c9ef5c3a..dec1c806 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -78,7 +78,14 @@ def _mock_db( return db -def _make_resp(status: int = 200, text: str = "ok") -> MagicMock: +# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница (без +# блока контактов / меньше settings.yandex_detail_min_html_bytes) теперь отсеивается +# ДО parse и считается отказом, поэтому заглушка «ok» больше не годится +# для кейсов, где ожидается обогащение. +_FULL_PAGE_HTML = "" + "x" * 1_100_000 + '"encryptedPhones":["a"]' + + +def _make_resp(status: int = 200, text: str = _FULL_PAGE_HTML) -> MagicMock: """Fake curl_cffi response.""" resp = MagicMock() resp.status_code = status diff --git a/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py b/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py new file mode 100644 index 00000000..5e1ad9dc --- /dev/null +++ b/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py @@ -0,0 +1,158 @@ +"""Недогруженная карточка Яндекса — отказ, а не успех (#3191). + +Наблюдение 2026-08-28 (три карточки, прод-сайдкар, аноним): + + offer 52275656 3 920 119 б encryptedPhones 65 + offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз + offer 12108408 4 732 884 б encryptedPhones 65 + +Средняя вернулась вдвое меньше нормы и БЕЗ блока контактов. Не капча и не ошибка: +HTTP 200, HTML валиден, `window.INITIAL_STATE` на месте, парсер отрабатывает. +Единственная проверка размера в провайдере (`newbuilding.py`, `len(html) < 500`) +отвечает на вопрос «пришло ли хоть что-то» — 1,8 МБ проходит её в 3600 раз, и +частичная карточка уезжала в БД с `detail_enriched_at`, выбывая из очереди навсегда. + +Проверка ПО ЗНАЧЕНИЮ: сравниваются счётчики и факт вызова записи, а не «не бросило +исключение». Первый кейс — недогруз (карточка НЕ должна сохраниться), второй — +регресс на полной странице (сохраняется как раньше). Тождество attempted (#3338) +проверяется в обоих: incomplete ⊆ failed, как blocked. +""" + +from __future__ import annotations + +import os +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 + +from app.tasks.yandex_detail_backfill import ( # noqa: E402 + YandexDetailBackfillResult, + run_yandex_detail_backfill, +) + +_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" +_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" +_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment" +_RUNS = "app.tasks.yandex_detail_backfill.runs_mod" +_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" +_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" + +# Размеры взяты с наблюдения: недогруз 1,8 МБ (порог в 1 МБ проходит — ловит его +# именно отсутствие блока контактов), полная карточка 3,9 МБ. Маркер написан здесь +# литералом НАРОЧНО: тест не должен падать на импорте, когда правку снимают — +# фальсификация обязана краснеть по значению («карточка успешна»), а не по ImportError. +CONTACTS_MARKER = "encryptedPhones" +UNDERRENDERED_HTML = "" + "x" * 1_805_556 + "" +FULL_HTML = "" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]' + + +def _mock_db(n: int) -> MagicMock: + snapshot = [ + {"id": i + 1, "source_url": f"https://realty.yandex.ru/offer/{i + 1}/"} for i in range(n) + ] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + sel.one.return_value = SimpleNamespace(url_from_offer_id=0, unenrichable_pending=0) + db.execute.return_value = sel + return db + + +def _resp(html: str) -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.text = html + return resp + + +async def _run(html: str) -> tuple[YandexDetailBackfillResult, MagicMock]: + """Один прогон на одну карточку. parse НАРОЧНО отдаёт разобранную модель: + именно так и было на проде — частичная страница парсится успешно.""" + save = MagicMock(return_value=True) + db = _mock_db(1) + session = AsyncMock() + session.get = AsyncMock(side_effect=[_resp(html)]) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=None) + + with ( + patch(_ASYNC_SESSION, MagicMock(return_value=ctx)), + patch(_PARSE, return_value=MagicMock()), + patch(_SAVE, save), + patch(_RUNS, MagicMock()), + patch(_SLEEP, new_callable=AsyncMock), + patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), + ): + counters = await run_yandex_detail_backfill( + db, run_id=3191, params={"batch_size": 1, "budget_sec": 3600} + ) + return counters, save + + +@pytest.mark.asyncio +async def test_underrendered_page_is_refusal_not_success() -> None: + """1,8 МБ без блока контактов → incomplete, detail_enriched_at не проставлен.""" + counters, save = await _run(UNDERRENDERED_HTML) + + # save_detail_enrichment — единственный писатель detail_enriched_at (UPDATE ... + # detail_enriched_at = NOW()). Не вызван = карточка осталась в очереди, её + # возьмёт следующий снапшот (WHERE detail_enriched_at IS NULL). + save.assert_not_called() + assert (counters.enriched, counters.incomplete, counters.failed) == (0, 1, 1), ( + f"enriched={counters.enriched} incomplete={counters.incomplete} " + f"failed={counters.failed}: недогруз обязан быть отказом, а не успехом" + ) + assert counters.blocked == 0, ( + f"blocked={counters.blocked}: площадка ответила HTTP 200 — это не блок" + ) + # Тождество #3338: incomplete ⊆ failed, в сумму исходов входит только failed. + assert counters.attempted == counters.enriched + counters.failed == 1 + + +@pytest.mark.asyncio +async def test_full_page_still_enriches() -> None: + """Регресс: полная карточка сохраняется как раньше.""" + counters, save = await _run(FULL_HTML) + + save.assert_called_once() + assert (counters.enriched, counters.incomplete, counters.failed) == (1, 0, 0), ( + f"enriched={counters.enriched} incomplete={counters.incomplete} " + f"failed={counters.failed}: полная страница должна обогащать как раньше" + ) + assert counters.attempted == counters.enriched + counters.failed == 1 + + +def test_reason_names_the_two_signals() -> None: + """Признака два, и любой из них — отказ.""" + from scraper_kit.providers.yandex.detail import detail_incomplete_reason + + assert detail_incomplete_reason(FULL_HTML) is None + # Структурный: 1,8 МБ проходит размерный порог, но контактов нет. + assert CONTACTS_MARKER in (detail_incomplete_reason(UNDERRENDERED_HTML) or "") + # Размерный: контакты есть, а страница обрезана. + reason = detail_incomplete_reason(f'"{CONTACTS_MARKER}":[]', min_html_bytes=1_000_000) + assert reason is not None and "недогружена" in reason + + +def test_redirect_phones_alone_counts_as_complete() -> None: + """Карточка «без телефона / только чат»: redirectPhones есть, encryptedPhones нет. + + Класс в замер #3192 не попадал (там у всех 12 были оба ключа). Требуя именно + encryptedPhones, мы браковали бы такие карточки вечно — они возвращаются в очередь, + а ключ у них не появится. Маркеры литералами: правку снимут — тест обязан краснеть + по значению («карточка отбракована»), а не по ImportError. + """ + from scraper_kit.providers.yandex.detail import detail_incomplete_reason + + body = "" + "x" * 3_920_119 + assert detail_incomplete_reason(body + '"redirectPhones":["a"]') is None + # Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз. + assert detail_incomplete_reason(body + "") is not None diff --git a/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py b/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py index 00e6e0cf..3404a71b 100644 --- a/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py +++ b/tradein-mvp/backend/tests/test_3196_yandex_ban_kind.py @@ -87,10 +87,16 @@ def _mock_db(n: int) -> MagicMock: return db +# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до +# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про +# природу блока. Маркер контактов + размер выше порога — оба признака полноты. +_FULL_PAGE_HTML = "" + "x" * 1_100_000 + '"encryptedPhones":["a"]' + + def _resp(status: int) -> MagicMock: resp = MagicMock() resp.status_code = status - resp.text = "отказ" + resp.text = _FULL_PAGE_HTML if status == 200 else "отказ" return resp diff --git a/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py b/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py index 555dadf3..c390f8d5 100644 --- a/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py +++ b/tradein-mvp/backend/tests/test_3338_backfill_counter_identity.py @@ -127,10 +127,16 @@ def _mock_yandex_db(n: int) -> MagicMock: return db +# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница теперь +# отсеивается до parse и даёт исход incomplete ⊆ failed — иначе эти кейсы про +# тождество счётчиков молча стали бы кейсами про полноту. +_FULL_PAGE_HTML = "" + "x" * 1_100_000 + '"encryptedPhones":["a"]' + + def _resp(status: int) -> MagicMock: resp = MagicMock() resp.status_code = status - resp.text = "ok" + resp.text = _FULL_PAGE_HTML if status == 200 else "ok" return resp diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py index 8e08b344..6decbf5c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py @@ -43,6 +43,48 @@ from scraper_kit.yandex_helpers import ( logger = logging.getLogger(__name__) +# ── Полнота страницы (#3191) ────────────────────────────────────────────────── +# Недорендеренная карточка приходит с HTTP 200 и валидным HTML: window.INITIAL_STATE +# на месте, `parse` отрабатывает и возвращает заполненную модель — а блока контактов +# на странице нет вовсе. Замер 2026-08-28 на трёх карточках прод-трактом: +# offer 52275656 3 920 119 б encryptedPhones 65 +# offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз +# offer 12108408 4 732 884 б encryptedPhones 65 +# Ключи выбраны так, чтобы они были у ПОЛНЫХ карточек в том же режиме, в каком мы ходим: +# оба отдаются и анонимной сессии (app/services/yandex_session.py и миграция 274 — с +# куками и без есть encryptedPhones + redirectPhones, отличается только mainPhone, +# поэтому mainPhone признаком полноты быть не может). +# Достаточно ЛЮБОГО из двух: замером #3192 покрыты 12 карточек, у всех были оба ключа, +# но класс «без телефона / только чат» в замер не попал. Требуя encryptedPhones, мы +# браковали бы такие карточки ВЕЧНО (в очередь они возвращаются, а ключ не появится). +DETAIL_CONTACTS_MARKERS = ("encryptedPhones", "redirectPhones") + +# Второй, независимый признак. Полная карточка — 3-5 МБ; порог держим заметно ниже +# наблюдавшегося недогруза (1,8 МБ), потому что размер зависит от объявления. Он ловит +# не тот случай, что маркер, а обрыв обратной формы: контакты отрендерились, остальное нет. +DEFAULT_MIN_HTML_BYTES = 1_000_000 + + +def detail_incomplete_reason( + html: str | None, + *, + min_html_bytes: int = DEFAULT_MIN_HTML_BYTES, +) -> str | None: + """Причина считать страницу недогруженной, либо None если карточка полная. + + Признака два, и любой из них — отказ (не оба сразу): наблюдавшийся недогруз в + 1,8 МБ размерный порог проходит с запасом, а контактов не несёт. + """ + if not html: + return "empty html" + if not any(marker in html for marker in DETAIL_CONTACTS_MARKERS): + markers = "/".join(DETAIL_CONTACTS_MARKERS) + return f"нет блока контактов ({markers} отсутствуют), {len(html)} б" + if len(html) < min_html_bytes: + return f"страница недогружена: {len(html)} б < {min_html_bytes} б" + return None + + # ── Pydantic models ─────────────────────────────────────────────────────────── @@ -143,6 +185,12 @@ class YandexDetailScraper(BaseScraper): if response.status_code != 200: logger.warning("yandex detail returned %d for %s", response.status_code, offer_url) return None + # Недогруз — отказ, а не успех (#3191): частичная карточка, разобранная как + # полная, уезжает в БД с detail_enriched_at и выбывает из очереди навсегда. + incomplete = detail_incomplete_reason(response.text) + if incomplete is not None: + logger.warning("yandex detail incomplete, отказ: %s — %s", offer_url, incomplete) + return None result = self.parse(response.text, offer_url=offer_url) await self.sleep_between_requests() return result