fix(tradein/yandex): в очередь обогащения не берём то, что парсер отвергает до сети (#2674) (#2738)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
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 3m7s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m13s

This commit is contained in:
bot-backend 2026-08-06 15:22:21 +00:00
parent f0968c8513
commit 4aec49f7fb
2 changed files with 123 additions and 4 deletions

View file

@ -17,6 +17,14 @@ max_consecutive_blocks. Прогон с нулём обогащений тепе
этот брейкер (attempted=5 failed=5) и все 31 назывались успешными. Остаток
снапшота уедет в следующую ночь через NULL detail_enriched_at.
Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии
у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url
ведёт на сайт застройщика, а не на realty.yandex.ru/offer/<id>/. Парсер отвергает
такие URL регуляркой ДО сети это не капча, а предрешённый parseNone. Идут они
пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон
целиком. Теперь снапшот-SELECT берёт только то, что парсер в принципе может
разобрать, а размер отброшенного видно в counters.unenrichable_pending.
Why curl_cffi and not YandexDetailScraper.fetch_detail:
fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS
fingerprinting). On datacenter IPs Yandex returns captcha / shell-HTML
@ -43,10 +51,27 @@ from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = [
"OFFER_URL_PATTERN",
"YandexDetailBackfillResult",
"run_yandex_detail_backfill",
]
# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс).
# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него
# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть
# отказ предрешён регуляркой, а не капчей.
#
# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют
# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru,
# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. Обогащено из
# них за всю историю 0; все 1 210 обогащённых — вида realty.yandex.ru/offer/<id>/.
#
# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один
# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон:
# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды.
# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим.
OFFER_URL_PATTERN = "/offer/[0-9]+"
@dataclass
class YandexDetailBackfillResult:
@ -55,6 +80,7 @@ class YandexDetailBackfillResult:
attempted: int = 0
enriched: int = 0
failed: int = 0
unenrichable_pending: int = 0
duration_sec: float = field(default=0.0)
def to_dict(self) -> dict[str, int]:
@ -62,6 +88,7 @@ class YandexDetailBackfillResult:
"attempted": self.attempted,
"enriched": self.enriched,
"failed": self.failed,
"unenrichable_pending": self.unenrichable_pending,
"duration_sec": int(self.duration_sec),
}
@ -105,6 +132,9 @@ async def run_yandex_detail_backfill(
# SNAPSHOT: single SELECT at start -- NOT re-selected in loop.
# Priority: is_active DESC (active first), scraped_at DESC (newest first).
# Гейт по OFFER_URL_PATTERN — тот же признак, по которому парсер отказывает
# (см. комментарий у константы): в очередь не берём то, что заведомо
# непарсимо, иначе пачка карточек застройщика обрывает прогон брейкером.
snapshot = (
db.execute(
text(
@ -114,23 +144,53 @@ async def run_yandex_detail_backfill(
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url ~ CAST(:offer_url_pattern AS text)
ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST
LIMIT CAST(:batch_size AS int)
"""
),
{"batch_size": batch_size},
{"batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN},
)
.mappings()
.all()
)
# Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено
# 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674).
counters.unenrichable_pending = int(
db.execute(
text(
"""
SELECT count(*)
FROM listings
WHERE source = 'yandex'
AND detail_enriched_at IS NULL
AND source_url IS NOT NULL
AND source_url !~ CAST(:offer_url_pattern AS text)
"""
),
{"offer_url_pattern": OFFER_URL_PATTERN},
).scalar_one()
)
if counters.unenrichable_pending:
logger.info(
"yandex_detail_backfill: run_id=%d%d объявлений вне очереди: "
"source_url ведёт не на карточку Яндекса (%s), парсер их отвергает "
"до сети",
run_id,
counters.unenrichable_pending,
OFFER_URL_PATTERN,
)
if not snapshot:
logger.info(
"yandex_detail_backfill: run_id=%d -- no pending listings "
"(detail_enriched_at IS NULL = 0), done",
run_id,
)
runs_mod.mark_done(db, run_id, current_counters)
# to_dict(), а не current_counters: пустая очередь при непустом
# unenrichable_pending — самый важный случай этого счётчика.
runs_mod.mark_done(db, run_id, counters.to_dict())
return counters
logger.info(

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import re
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -50,11 +51,13 @@ def _make_snapshot(n: int) -> list[dict]:
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
"""Fake Session: first execute() returns snapshot via .mappings().all()."""
def _mock_db(snapshot: list[dict], unenrichable: int = 0) -> MagicMock:
"""Fake Session: execute() отдаёт снапшот через .mappings().all(), а
.scalar_one() размер отброшенной (непарсимой) части очереди."""
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
sel.scalar_one.return_value = unenrichable
db.execute.return_value = sel
return db
@ -448,3 +451,59 @@ def test_save_detail_enrichment_rowcount_zero_returns_false() -> None:
saved = save_detail_enrichment(db, listing_id=404, e=enrichment)
assert saved is False
# ---------------------------------------------------------------------------
# Очередь не должна содержать того, что парсер отвергает до сети (2026-08-06)
# ---------------------------------------------------------------------------
# Реальные source_url с прода (2026-08-06). Верх очереди на момент прогона 3300
# состоял ровно из таких строк: 5 попыток, 5 parse-None, abort за 23 секунды.
_PROD_QUEUE_HEAD = [
("https://macroserver.ru/id/224566/", False),
("https://prospect-federation.ru/flat/192", False),
("https://macroserver.ru/id/7223953/", False),
("https://strana.com/ekaterinburg/flat/1234", False),
("https://realty.yandex.ru/offer/7416316701146842927/", True),
("https://realty.yandex.ru/offer/7298311881327827251/", True),
]
@pytest.mark.asyncio
async def test_queue_gate_matches_parser_gate_and_counts_rest() -> None:
"""Снапшот-SELECT судит по тому же признаку, что и парсер, — сторожем, а не на слово.
`YandexDetailScraper.parse` возвращает None по регулярке в URL, ещё не
заглянув в HTML. Строки шире этого условия гарантированно дают parse-None и
пачкой выбивают брейкер «5 подряд», обрывая ВЕСЬ прогон (32 прогона из 53 на
проде). Проверяем на одних и тех же прод-URL обе стороны + что отброшенное
посчитано, а не молча исчезло.
"""
from scraper_kit.providers.yandex.detail import YandexDetailScraper
db = _mock_db([], unenrichable=3535)
runs = MagicMock()
session_cls, _session = _make_session_ctx([])
with (
patch(_ASYNC_SESSION, session_cls),
patch(_RUNS, runs),
patch(_SETTINGS, _mock_settings()),
):
result = await run_yandex_detail_backfill(
db, run_id=42, params={"batch_size": 10, "budget_sec": 60}
)
snapshot_call = db.execute.call_args_list[0]
assert "source_url ~ CAST(:offer_url_pattern AS text)" in str(snapshot_call.args[0])
pattern = snapshot_call.args[1]["offer_url_pattern"]
scraper = YandexDetailScraper()
for url, enrichable in _PROD_QUEUE_HEAD:
assert (re.search(pattern, url) is not None) is enrichable, url
if not enrichable:
# HTML тут любой: отказ предрешён до его разбора.
assert scraper.parse("<html><body>сайт застройщика</body></html>", url) is None, url
assert result.unenrichable_pending == 3535
assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535