fix(tradein): недогруженная карточка Яндекса — отказ, а не успех (#3191)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m11s

Страница на 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 выглядят
полной страницей — иначе они молча стали бы кейсами про полноту.
This commit is contained in:
bot-backend 2026-09-05 23:59:15 +05:00
parent 63dbc209b2
commit 4b5f8fb54c
7 changed files with 277 additions and 5 deletions

View file

@ -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)."""

View file

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

View file

@ -78,7 +78,14 @@ def _mock_db(
return db
def _make_resp(status: int = 200, text: str = "<html>ok</html>") -> MagicMock:
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница (без
# блока контактов / меньше settings.yandex_detail_min_html_bytes) теперь отсеивается
# ДО parse и считается отказом, поэтому заглушка «<html>ok</html>» больше не годится
# для кейсов, где ожидается обогащение.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _make_resp(status: int = 200, text: str = _FULL_PAGE_HTML) -> MagicMock:
"""Fake curl_cffi response."""
resp = MagicMock()
resp.status_code = status

View file

@ -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 = "<html>" + "x" * 1_805_556 + "</html>"
FULL_HTML = "<html>" + "x" * 3_920_119 + f'"{CONTACTS_MARKER}":["a","b"]</html>'
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

View file

@ -87,10 +87,16 @@ def _mock_db(n: int) -> MagicMock:
return db
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруз теперь отсеивается до
# parse, и страница-заглушка превратила бы эти кейсы в тесты про полноту, а не про
# природу блока. Маркер контактов + размер выше порога — оба признака полноты.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>отказ</html>"
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>отказ</html>"
return resp

View file

@ -127,10 +127,16 @@ def _mock_yandex_db(n: int) -> MagicMock:
return db
# HTTP 200 обязан выглядеть ПОЛНОЙ карточкой (#3191): недогруженная страница теперь
# отсеивается до parse и даёт исход incomplete ⊆ failed — иначе эти кейсы про
# тождество счётчиков молча стали бы кейсами про полноту.
_FULL_PAGE_HTML = "<html>" + "x" * 1_100_000 + '"encryptedPhones":["a"]</html>'
def _resp(status: int) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.text = "<html>ok</html>"
resp.text = _FULL_PAGE_HTML if status == 200 else "<html>ok</html>"
return resp

View file

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