fix(tradein/yandex): недорендеренная карточка — отказ, не успех: структурный маркер encryptedPhones + порог размера, карточка остаётся в очереди #3364
7 changed files with 299 additions and 5 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue