Merge pull request 'fix(tradein/yandex): недорендеренная карточка — отказ, не успех: структурный маркер encryptedPhones + порог размера, карточка остаётся в очереди' (#3364) from fix/3191-yandex-detail-completeness into main
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 2m4s
Deploy Trade-In / deploy (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-05 19:21:06 +00:00
commit 6ced16618c
7 changed files with 299 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_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)."""

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

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,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 = "<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
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 = "<html>" + "x" * 3_920_119
assert detail_incomplete_reason(body + '"redirectPhones":["a"]</html>') is None
# Фальсификация: без ОБОИХ ключей та же страница полного размера — недогруз.
assert detail_incomplete_reason(body + "</html>") is not None

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