From d0071c57bcaacb2731d77f155aeaea8999851b09 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 08:37:40 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20=D0=AF=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20=D0=BC=D1=91=D1=80=D1=82=D0=B2?= =?UTF-8?q?=20=E2=80=94=20slug=20=D0=96=D0=9A=20=D0=B8=D1=89=D0=B5=D0=BC?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=20(#2860)?= =?UTF-8?q?=20(#2923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/product_handlers.py | 26 ++++- .../test_2860_yandex_sweep_run_status.py | 94 ++++++++++++++++ .../test_scraper_kit_newbuilding_endpoint.py | 72 +++++++++++++ .../providers/yandex/newbuilding.py | 100 ++++++++++++------ 4 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 6c2513ad..1f2adf0a 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -326,7 +326,31 @@ async def _job_yandex_newbuilding_sweep( request_delay_sec=float(params.get("request_delay_sec", 8.0)), city=str(params.get("city", "ekaterinburg")), ) - ctx.runs.mark_done(db, run_id, result.to_dict()) + counters = result.to_dict() + # #2860: прогон, который обработал дома и не разрешил НИ ОДНОГО, успешным + # называть нельзя. Четырнадцать таких прогонов подряд (16.07-17.08.2026) + # стояли `done` с пустым error_text — механизм исполнялся, счётчик был + # честный, вывод из него не делал никто, и очередь тихо росла 351 → 397. + # + # НЕ подгоняем счётчик: если дома действительно не разрешаются, честный + # исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля. + processed = int(counters.get("processed") or 0) + succeeded = int(counters.get("succeeded") or 0) + if processed > 0 and succeeded == 0: + logger.warning( + "yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — " + "помечаю прогон неуспешным", + run_id, + processed, + ) + ctx.runs.mark_failed( + db, + run_id, + f"обработано {processed} домов, разрешено 0 — полный отказ разрешения slug", + counters, + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id) try: diff --git a/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py new file mode 100644 index 00000000..fb10bf8e --- /dev/null +++ b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py @@ -0,0 +1,94 @@ +"""#2860: обход, не разрешивший ни одного дома, не должен числиться успешным. + +Четырнадцать прогонов подряд (16.07-17.08.2026) стояли `status='done'` с пустым +`error_text` при счётчиках `processed 5, succeeded 0`. Исключения не было — +поэтому `mark_done`; но прогон не сделал ничего из заявленного, очередь за это +время выросла 351 → 397, а витрина `market.yandex_jk_enrichment` замерла на +34 строках с 15.07. + +Счётчик при этом был честный. Не хватало вывода из него — ровно тот класс, +который эпик #2674 называет самым частым: «механизм исполняется, счётчик +честный, вывод из него никто не делает». + +НЕ подгоняем succeeded: если дома не разрешаются, честный исход — назвать +прогон неуспешным, а не дотянуть счётчик до зелёного. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Settings требует DATABASE_URL на импорте; соседние тесты хендлеров делают так же +# (см. test_scrape_skip_visibility.py) — коннекта не будет, нужен только парс. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.product_handlers import _job_yandex_newbuilding_sweep + + +def _ctx() -> SimpleNamespace: + return SimpleNamespace(runs=MagicMock()) + + +def _result(processed: int, succeeded: int) -> MagicMock: + r = MagicMock() + r.to_dict = MagicMock( + return_value={"processed": processed, "succeeded": succeeded, "total": 425} + ) + return r + + +@pytest.mark.asyncio +async def test_zero_resolved_marks_run_failed() -> None: + """processed > 0, succeeded == 0 → прогон неуспешен, причина названа.""" + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(5, 0)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 4156, {}, ctx) + + ctx.runs.mark_done.assert_not_called() + ctx.runs.mark_failed.assert_called_once() + reason = ctx.runs.mark_failed.call_args[0][2] + assert "разрешено 0" in reason, reason + + +@pytest.mark.asyncio +async def test_partial_success_still_done() -> None: + """Контроль: хотя бы один разрешённый дом — прогон по-прежнему успешен. + + Зелёный с обеих сторон правки. Без него правка могла бы объявить неуспешным + любой неполный прогон — а `succeeded: 1 из 5` был нормой в июле, когда + механизм работал. + """ + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(5, 1)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 1489, {}, ctx) + + ctx.runs.mark_failed.assert_not_called() + ctx.runs.mark_done.assert_called_once() + + +@pytest.mark.asyncio +async def test_empty_queue_is_not_a_failure() -> None: + """Контроль: обрабатывать было нечего (processed == 0) — это не отказ. + + Иначе гейт краснел бы каждый раз, когда очередь разобрана до конца, то есть + ровно в успешном состоянии. + """ + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(0, 0)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 9999, {}, ctx) + + ctx.runs.mark_failed.assert_not_called() + ctx.runs.mark_done.assert_called_once() diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index d2890511..ee7509ab 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -158,6 +158,78 @@ async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch): assert kwargs.get("endpoint") is None +# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ──────────────── +# +# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=` Яндексом +# больше не применяется — отдаётся общий список новостроек (1.7 МБ, 35 разных +# ЖК), запрошенного id среди них нет. Разметка при этом цела: прежний regex +# находил 128 ссылок нужной формы, просто ни одной с нужным id. Из-за этого +# обход не разрешил НИ ОДНОГО дома с 16.07.2026 (14 прогонов подряд 5/0). + +_JK_PAGE_HTML = ( + "ЖК «Успенский»" + 'эта же страница' + 'ЖК «Успенский»' + "" +) + +# Общий список: ссылок много, с нужным id — ни одной. Ровно то, что прод +# отдавал на `?siteId=`. +_GENERAL_LIST_HTML = ( + 'ШишкINN' + 'Парковый' +) + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypatch): + """Запрашивается страница ЖК по id, а НЕ выдача с ?siteId=. + + Это и есть суть правки: адрес запроса, а не разбор ответа. + """ + spy = _spy_browser_fetcher(_JK_PAGE_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + await resolve_yandex_jk_slug("286394") + + url = spy.return_value.fetch.call_args[0][0] + assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}" + assert url.endswith("/kupit/novostrojka/zhk-286394/"), url + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch): + """Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL.""" + spy = _spy_browser_fetcher(_JK_PAGE_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") == "uspenskij" + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch): + """Отрицательный контроль: ссылки есть, с нашим id — нет → None, а не чужой slug. + + Прод-эквивалент: несуществующий id 999999999 отдаёт общий список. + Без привязки к id функция вернула бы «shishkinn» для чужого дома. + """ + spy = _spy_browser_fetcher(_GENERAL_LIST_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") is None + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch): + """Если на странице только наша же ссылка — это не разрешение, а эхо.""" + spy = _spy_browser_fetcher( + 'сама страница' + ) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") is None + + # ── cian.newbuilding.resolve_cian_zhk_url_via_search ───────────────────────── # NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike # fetch_newbuilding above) — it builds its own curl_cffi session via diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py index 052fabd5..583e8361 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py @@ -314,7 +314,25 @@ class YandexNewbuildingScraper(BaseScraper): ) -# ── Slug resolution via SERP ────────────────────────────────────────────────── +# ── Slug resolution по id страницы ЖК ───────────────────────────────────────── + + +# Плейсхолдер вместо неизвестного slug: в URL ЖК авторитетен ИМЕННО id, а slug +# — косметика. Проверено на проде 19.08.2026: /zhk-286394/ отдаёт страницу +# ЖК «Успенский», /zhk-2671892/ — «ШишкINN». Значение слова роли не играет, +# важно лишь, что оно не пустое (Яндекс не принимает `/-/`). +_JK_SLUG_PLACEHOLDER = "zhk" + + +def _slug_link_re(jk_id: str) -> re.Pattern[str]: + """Ссылка на ЖК С ЭТИМ ЖЕ id: /kupit/novostrojka/-/. + + id зашит в шаблон намеренно — общий `_JK_SLUG_RE` матчит ЛЮБУЮ пару + slug-id и на общем списке новостроек даёт 128 совпадений, среди которых + нужного может не быть вовсе. Привязка к id — то, что отличает «нашли ЖК» + от «нашли какой-то ЖК». + """ + return re.compile(rf"/kupit/novostrojka/([a-z0-9-]+)-{re.escape(str(jk_id))}/") async def resolve_yandex_jk_slug( @@ -323,14 +341,26 @@ async def resolve_yandex_jk_slug( *, config: ScraperConfig | None = None, ) -> str | None: - """Найти Yandex Realty slug для ЖК по его ext_id (jk_id) через SERP. + """Найти Yandex Realty slug для ЖК по его ext_id (jk_id). - Стратегия (#974 — зеркало resolve_cian_zhk_url_via_search): - 1. Запросить поисковую страницу Yandex Realty через BrowserFetcher. - URL: /ekaterinburg/kupit/novostrojka/?siteId= - 2. В HTML найти первую ссылку вида //kupit/novostrojka/-/ - через regex _JK_SLUG_RE. - 3. Вернуть slug или None при любой ошибке. + Стратегия: запросить страницу ЖК по id с плейсхолдером вместо slug + (`/{city}/kupit/novostrojka/zhk-/`) и прочитать настоящий slug из + ссылок на самой странице. В URL Яндекса авторитетен id, поэтому любой + непустой slug доезжает до нужной страницы. + + ПОЧЕМУ НЕ SERP (замер 19.08.2026, issue #2860). До этой правки slug искали + на поисковой выдаче с фильтром `?siteId=`. Фильтр Яндексом больше НЕ + применяется: страница отдаёт общий список новостроек — 1.7 МБ, 35 разных + ЖК, и запрошенного id среди них нет. Проверено на двух разных id, ответы + почти совпадают. Разметка при этом цела: старый regex находил 128 ссылок + нужной формы — то есть парсер работал, а стратегия умерла. + + Из-за этого обход не разрешал НИ ОДНОГО дома с 16.07.2026: четырнадцать + прогонов подряд `processed 5, succeeded 0`, витрина замерла на 34 строках, + очередь выросла 351 → 397. + + Отрицательный контроль (тот же замер): несуществующий id 999999999 отдаёт + общий список, ссылок с этим id нет → функция вернёт None, а не чужой slug. Caller несёт ответственность за anti-bot sleep (зеркало cian_newbuilding.py). BrowserFetcher обязателен — Yandex Realty JS/anti-bot, httpx/curl не работают. @@ -338,42 +368,50 @@ async def resolve_yandex_jk_slug( Args: jk_id: Yandex Realty ext_id ЖК. city: город (по умолчанию ekaterinburg). - config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher - (#2322 fix: раньше не принимался вообще, BrowserFetcher(source="yandex") - конструировался без endpoint= → TypeError на любом вызове). + config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher. Returns: - slug (str без id-суффикса), или None при ошибке / не найден. + slug (str без id-суффикса), или None. Причина None всегда попадает в лог + ОТДЕЛЬНОЙ формулировкой — «не ответил браузер», «пустой HTML» и «страницы + такого ЖК нет» требуют разных действий, и сливать их в одно сообщение + значит заставлять следующего читателя гадать. """ - # Yandex Realty SERP: фильтр по siteId → первый результат = нужный ЖК. - # Альтернативный путь через Яндекс Поиск (web SERP) менее надёжен из-за - # вариативности разметки. Прямой realty.yandex.ru SERP — стабильнее. - serp_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/?siteId={jk_id}" + page_url = ( + f"https://realty.yandex.ru/{city}/kupit/novostrojka/" + f"{_JK_SLUG_PLACEHOLDER}-{jk_id}/" + ) endpoint = config.browser_http_endpoint if config is not None else None try: async with BrowserFetcher(source="yandex", endpoint=endpoint) as fetcher: - html = await fetcher.fetch(serp_url) + html = await fetcher.fetch(page_url) except Exception as exc: - logger.warning("resolve_yandex_jk_slug jk_id=%s browser fetch failed: %s", jk_id, exc) + logger.warning("resolve_yandex_jk_slug jk_id=%s: браузер не ответил: %s", jk_id, exc) return None if not html: - logger.warning("resolve_yandex_jk_slug jk_id=%s: empty HTML from browser", jk_id) + logger.warning("resolve_yandex_jk_slug jk_id=%s: пустой HTML от браузера", jk_id) return None - # Ищем ссылку вида /{city}/kupit/novostrojka/-/ - # Ограничиваем: id в ссылке должен совпадать с искомым jk_id. - for m in _JK_SLUG_RE.finditer(html): - if m.group(2) == str(jk_id): - slug = m.group(1) - logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug) - return slug + # Плейсхолдер отбрасываем: страница ссылается сама на себя запрошенным + # адресом, и без этого фильтра мы бы «разрешили» slug в тот, что сами же + # и придумали — то есть записали бы выдумку как факт. + slugs = [ + slug + for slug in _slug_link_re(jk_id).findall(html) + if slug != _JK_SLUG_PLACEHOLDER + ] + if not slugs: + logger.warning( + "resolve_yandex_jk_slug jk_id=%s: на странице нет ни одной ссылки с этим id " + "(ЖК снят с публикации либо id больше не действителен); длина HTML=%d", + jk_id, + len(html), + ) + return None - logger.warning( - "resolve_yandex_jk_slug jk_id=%s: no matching slug in SERP HTML (markup drift?)", - jk_id, - ) - return None + slug = slugs[0] + logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug) + return slug # ── helpers ───────────────────────────────────────────────────────────────────