diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 16873d03..6538c7c8 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -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//. Парсер отвергает +такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они +пачками, поэтому «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//. +# +# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один +# застройщик) и упираются в брейкер «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( 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 ccd3d2af..2d9f320a 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_detail_backfill.py @@ -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("сайт застройщика", url) is None, url + + assert result.unenrichable_pending == 3535 + assert runs.mark_done.call_args.args[2]["unenrichable_pending"] == 3535