From 4b5f8fb54c099354c9438ecc7a97f0459acd5baa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 5 Sep 2026 23:59:15 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20=D0=BD=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=AF=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=B0=20=E2=80=94=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7,=20=D0=B0=20=D0=BD=D0=B5=20=D1=83=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=85=20(#3191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница на 1,8 МБ без блока контактов приходит с HTTP 200 и валидным HTML: window.INITIAL_STATE на месте, parse отрабатывает — и частичная карточка уезжала в БД с detail_enriched_at, выбывая из очереди навсегда. Единственная проверка размера (newbuilding.py, len(html) < 500) отвечала на вопрос «пришло ли хоть что-то»: 1,8 МБ проходит её в 3600 раз. Признак полноты структурный + размерный, любой из двух даёт отказ: encryptedPhones (65 вхождений у полных карточек, 0 у недогруза; отдаётся и анонимной сессии — см. yandex_session.py) и settings.yandex_detail_min_html_bytes (1 МБ). Наблюдавшийся недогруз ловит именно структурный: 1,8 МБ порог проходит. В backfill проверка стоит ДО parse: исход incomplete ⊆ failed, save не вызывается, значит detail_enriched_at не проставляется и следующий снапшот (detail_enriched_at IS NULL) возьмёт объявление снова. Серия недогрузов двигает consecutive_none — тот же брейкер, что у parse→None, поэтому вечно недогружаемая карточка обрывает прогон, а не молотится (per-listing счётчика попыток в схеме нет). Фейковые ответы в тестах-соседях (#3196/#3338) теперь при HTTP 200 выглядят полной страницей — иначе они молча стали бы кейсами про полноту. --- tradein-mvp/backend/app/core/config.py | 8 + .../app/tasks/yandex_detail_backfill.py | 63 +++++++- .../tasks/test_yandex_detail_backfill.py | 9 +- .../test_3191_yandex_detail_completeness.py | 142 ++++++++++++++++++ .../tests/test_3196_yandex_ban_kind.py | 8 +- .../test_3338_backfill_counter_identity.py | 8 +- .../scraper_kit/providers/yandex/detail.py | 44 ++++++ 7 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 78e20839..b03dafa3 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_MARKER); любой из двух + # даёт отказ, объявление остаётся в очереди (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..4443c61f 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,45 @@ 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( + "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 +560,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..593ae744 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py @@ -0,0 +1,142 @@ +"""Недогруженная карточка Яндекса — отказ, а не успех (#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 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..510bdf01 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,44 @@ 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 +# Ключ выбран так, чтобы он был у ПОЛНЫХ карточек в том же режиме, в каком мы ходим: +# `encryptedPhones` отдаётся и анонимной сессии (app/services/yandex_session.py и +# миграция 274 — с куками и без есть encryptedPhones + redirectPhones, отличается +# только mainPhone, поэтому mainPhone признаком полноты быть не может). +DETAIL_CONTACTS_MARKER = "encryptedPhones" + +# Второй, независимый признак. Полная карточка — 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 DETAIL_CONTACTS_MARKER not in html: + return f"нет блока контактов ({DETAIL_CONTACTS_MARKER} отсутствует), {len(html)} б" + if len(html) < min_html_bytes: + return f"страница недогружена: {len(html)} б < {min_html_bytes} б" + return None + + # ── Pydantic models ─────────────────────────────────────────────────────────── @@ -143,6 +181,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 From 78e3da6bc31295c065ca6a9e6fabc41577648422 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 00:14:43 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein):=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=E2=80=94=20encryptedPhones=20=D0=98=D0=9B=D0=98=20redirectPhon?= =?UTF-8?q?es=20(#3191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3364. Требуя именно encryptedPhones, отбраковывали бы вечно необмеренный класс карточек «без телефона / только чат»: в очередь они возвращаются, а ключ не появится. redirectPhones измерен тем же замером #3192 и присутствует в обеих ветках (с куками и без). Текст ABORT: consecutive_none смешанный (фетч-ошибка + parse-None + недогруз) — «N подряд без обогащения», а не «недогруженных». --- tradein-mvp/backend/app/core/config.py | 2 +- .../app/tasks/yandex_detail_backfill.py | 4 +++- .../test_3191_yandex_detail_completeness.py | 16 ++++++++++++++++ .../src/scraper_kit/providers/yandex/detail.py | 18 +++++++++++------- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index b03dafa3..44109609 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1010,7 +1010,7 @@ class Settings(BaseSettings): # ── Порог полноты detail-страницы Яндекса (#3191) ──────────────────────── # Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным # HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к - # структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKER); любой из двух + # структурному (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 diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 4443c61f..31e7cad7 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -467,8 +467,10 @@ async def run_yandex_detail_backfill( ) if consecutive_none >= max_consecutive_blocks: logger.error( + # consecutive_none смешанный (фетч-ошибка + parse-None + + # недогруз) — «недогруженных» назвало бы только один вклад. "yandex_detail_backfill: run_id=%d ABORT -- %d подряд " - "недогруженных страниц. enriched=%d attempted=%d", + "без обогащения. enriched=%d attempted=%d", run_id, consecutive_none, counters.enriched, diff --git a/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py b/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py index 593ae744..5e1ad9dc 100644 --- a/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py +++ b/tradein-mvp/backend/tests/test_3191_yandex_detail_completeness.py @@ -140,3 +140,19 @@ def test_reason_names_the_two_signals() -> None: # Размерный: контакты есть, а страница обрезана. 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/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/detail.py index 510bdf01..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 @@ -50,11 +50,14 @@ logger = logging.getLogger(__name__) # offer 52275656 3 920 119 б encryptedPhones 65 # offer 62983459 1 805 556 б encryptedPhones 0 <- недогруз # offer 12108408 4 732 884 б encryptedPhones 65 -# Ключ выбран так, чтобы он был у ПОЛНЫХ карточек в том же режиме, в каком мы ходим: -# `encryptedPhones` отдаётся и анонимной сессии (app/services/yandex_session.py и -# миграция 274 — с куками и без есть encryptedPhones + redirectPhones, отличается -# только mainPhone, поэтому mainPhone признаком полноты быть не может). -DETAIL_CONTACTS_MARKER = "encryptedPhones" +# Ключи выбраны так, чтобы они были у ПОЛНЫХ карточек в том же режиме, в каком мы ходим: +# оба отдаются и анонимной сессии (app/services/yandex_session.py и миграция 274 — с +# куками и без есть encryptedPhones + redirectPhones, отличается только mainPhone, +# поэтому mainPhone признаком полноты быть не может). +# Достаточно ЛЮБОГО из двух: замером #3192 покрыты 12 карточек, у всех были оба ключа, +# но класс «без телефона / только чат» в замер не попал. Требуя encryptedPhones, мы +# браковали бы такие карточки ВЕЧНО (в очередь они возвращаются, а ключ не появится). +DETAIL_CONTACTS_MARKERS = ("encryptedPhones", "redirectPhones") # Второй, независимый признак. Полная карточка — 3-5 МБ; порог держим заметно ниже # наблюдавшегося недогруза (1,8 МБ), потому что размер зависит от объявления. Он ловит @@ -74,8 +77,9 @@ def detail_incomplete_reason( """ if not html: return "empty html" - if DETAIL_CONTACTS_MARKER not in html: - return f"нет блока контактов ({DETAIL_CONTACTS_MARKER} отсутствует), {len(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