fix(tradein): фильтр выдачи Яндекса мёртв — slug ЖК ищем по странице объекта (#2860) (#2923)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m42s

This commit is contained in:
bot-backend 2026-08-19 08:37:40 +00:00
parent e86f0782da
commit d0071c57bc
4 changed files with 260 additions and 32 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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=<id>` Яндексом
# больше не применяется — отдаётся общий список новостроек (1.7 МБ, 35 разных
# ЖК), запрошенного id среди них нет. Разметка при этом цела: прежний regex
# находил 128 ссылок нужной формы, просто ни одной с нужным id. Из-за этого
# обход не разрешил НИ ОДНОГО дома с 16.07.2026 (14 прогонов подряд 5/0).
_JK_PAGE_HTML = (
"<html><head><title>ЖК «Успенский»</title></head><body>"
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">эта же страница</a>'
'<a href="/ekaterinburg/kupit/novostrojka/uspenskij-286394/">ЖК «Успенский»</a>'
"</body></html>"
)
# Общий список: ссылок много, с нужным id — ни одной. Ровно то, что прод
# отдавал на `?siteId=`.
_GENERAL_LIST_HTML = (
'<a href="/ekaterinburg/kupit/novostrojka/shishkinn-2671892/">ШишкINN</a>'
'<a href="/ekaterinburg/kupit/novostrojka/parkovyj-1637230/">Парковый</a>'
)
@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(
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
)
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

View file

@ -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». Значение слова роли не играет,
# важно лишь, что оно не пустое (Яндекс не принимает `/-<id>/`).
_JK_SLUG_PLACEHOLDER = "zhk"
def _slug_link_re(jk_id: str) -> re.Pattern[str]:
"""Ссылка на ЖК С ЭТИМ ЖЕ id: /kupit/novostrojka/<slug>-<jk_id>/.
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=<jk_id>
2. В HTML найти первую ссылку вида /<city>/kupit/novostrojka/<slug>-<jk_id>/
через regex _JK_SLUG_RE.
3. Вернуть slug или None при любой ошибке.
Стратегия: запросить страницу ЖК по id с плейсхолдером вместо slug
(`/{city}/kupit/novostrojka/zhk-<jk_id>/`) и прочитать настоящий slug из
ссылок на самой странице. В URL Яндекса авторитетен id, поэтому любой
непустой slug доезжает до нужной страницы.
ПОЧЕМУ НЕ SERP (замер 19.08.2026, issue #2860). До этой правки slug искали
на поисковой выдаче с фильтром `?siteId=<jk_id>`. Фильтр Яндексом больше НЕ
применяется: страница отдаёт общий список новостроек 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/<slug>-<id>/
# Ограничиваем: 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 ───────────────────────────────────────────────────────────────────